From 74815a5d24280200dc83595944c534c766b80ce3 Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Tue, 7 Jul 2026 09:55:58 -0400 Subject: [PATCH 01/13] chore: midwork --- .../@aws-cdk/toolkit-lib/lib/api/io/index.ts | 5 + .../toolkit-lib/lib/api/io/listeners.ts | 220 ++++++++++++ .../toolkit-lib/lib/api/io/private/index.ts | 1 + .../lib/api/io/private/listener-registry.ts | 340 ++++++++++++++++++ .../lib/api/io/private/message-maker.ts | 6 +- .../lib/api/io/private/messages.ts | 228 ++++++------ .../toolkit-lib/lib/payloads/index.ts | 1 + .../toolkit-lib/test/api/io/listeners.test.ts | 340 ++++++++++++++++++ .../aws-cdk/lib/cli/io-host/cli-io-host.ts | 252 ++----------- 9 files changed, 1047 insertions(+), 346 deletions(-) create mode 100644 packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts create mode 100644 packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts create mode 100644 packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts index f1c7cb73d..f88697ffc 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts @@ -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, AbsentData } from './private/message-maker'; +export type { ActionLessMessage, ActionLessRequest } from './private/io-helper'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts new file mode 100644 index 000000000..2397aecb8 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts @@ -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 { + /** + * 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( + selector: IoMessageMaker | IoRequestMaker | ((msg: IoMessage) => msg is IoMessage), + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void; + on( + predicate: (msg: IoMessage) => boolean, + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void; + + /** + * Like `on`, but the listener is automatically removed after it has been + * invoked once. + */ + once( + selector: IoMessageMaker | IoRequestMaker | ((msg: IoMessage) => msg is IoMessage), + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void; + once( + predicate: (msg: IoMessage) => boolean, + listener: (msg: IoMessage) => 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( + code: IoMessageMaker | IoRequestMaker, + formatter: (msg: IoMessage) => string, + level?: IoMessageLevel, + ): () => void; + + /** + * Like `rewrite`, but the formatter is automatically removed after it has + * been applied once. + */ + rewriteOnce( + code: IoMessageMaker | IoRequestMaker, + formatter: (msg: IoMessage) => 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(code: IoRequestMaker, value: U, suppressQuestion?: boolean): () => void; + + /** + * Like `respond`, but the answer is given only once and then removed. + */ + respondOnce(code: IoRequestMaker, 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 { + 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`. + */ +class ListeningIoHost implements IoHostWithListeners { + private readonly registry = new ListenerRegistry(); + + public constructor(private readonly inner: IIoHost) { + } + + public async notify(msg: IoMessage): Promise { + const { message, preventDefault } = await this.registry.apply(msg); + if (preventDefault) { + return; + } + return this.inner.notify(message); + } + + public async requestResponse(msg: IoRequest): Promise { + 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, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { + return this.registry.on(selector as any, listener as any); + } + + public once(selector: MessageSelector, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { + return this.registry.once(selector as any, listener as any); + } + + public rewrite( + code: IoMessageMaker | IoRequestMaker, + formatter: (msg: IoMessage) => string, + level?: IoMessageLevel, + ): () => void { + return this.registry.rewrite(code, formatter, level); + } + + public rewriteOnce( + code: IoMessageMaker | IoRequestMaker, + formatter: (msg: IoMessage) => string, + level?: IoMessageLevel, + ): () => void { + return this.registry.rewriteOnce(code, formatter, level); + } + + public respond(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { + return this.registry.respond(code, value, suppressQuestion); + } + + public respondOnce(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { + return this.registry.respondOnce(code, value, suppressQuestion); + } +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/index.ts index e795860ff..77af47151 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/index.ts @@ -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'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts new file mode 100644 index 000000000..170375dfd --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts @@ -0,0 +1,340 @@ +import type { IoMessage, IoMessageLevel } from '../io-message'; +import type { IoMessageMaker, IoRequestMaker } from './message-maker'; + +/** + * The result a message listener may return to influence how a message is handled. + * + * A listener may update the message _text_ and/or its _level_; it cannot change + * any other field of the message (such as its `code`), which keeps the + * code-keyed listener registry valid. + */ +export interface MessageListenerResult { + /** + * Replace the text that is printed for this message. + * + * @default - the message text is left unchanged + */ + readonly message?: string; + + /** + * Override the level of this message. + * + * A host may use the level for verbosity filtering and for deciding where to + * route the message, so overriding it can change whether and where the message + * is shown. The `code` is intentionally left unchanged. + * + * @default - the message level is left unchanged + */ + readonly level?: IoMessageLevel; + + /** + * Skip the default handling of the message. + * + * For a notification this means the host is not asked to handle it. For a + * request it stops processing entirely: the host is not asked to prompt, and + * the request resolves with its (possibly `respond`-overridden) default + * response. + * + * @default false + */ + readonly preventDefault?: boolean; + + /** + * For requests only: the value to resolve the request with. It is folded into + * the request's default response and skips the prompt (the host is not asked + * to answer). The question is still surfaced unless `preventDefault` is also + * set. Ignored for plain notifications. + * + * The presence of the key is what matters, so `false`/`0`/`''` are valid + * answers. Use the `respond`/`respondOnce` helpers for the common case. + * + * @default - this listener does not supply a response + */ + readonly respond?: unknown; +} + +/** + * What a message listener may return: nothing, a `MessageListenerResult`, or a + * `Promise` of either. + * + * Listeners may be async. The registry awaits each listener before running the + * next, so registration order — and the cumulative effect on the message — is + * preserved regardless of whether listeners are sync or async. + */ +export type MessageListenerResultOrPromise = void | MessageListenerResult | Promise; + +/** + * Selects which messages a listener applies to. + * + * Either a message/request *maker* — the listener fires for messages with that + * maker's `code` — or a custom *predicate* over the message. A maker's `.is` + * type guard (e.g. `IO.CDK_TOOLKIT_I7010.is`) is a convenient predicate, but any + * `(msg) => boolean` works (e.g. to match a family of codes, or on the message + * level). Use `matchAny` to combine several selectors. + */ +export type MessageSelector = + | IoMessageMaker + | IoRequestMaker + | ((msg: IoMessage) => boolean); + +/** + * A function a listener runs when a matching message appears. + */ +export type MessageListenerFn = (msg: IoMessage) => MessageListenerResultOrPromise; + +/** + * A registered message listener. + */ +interface MessageListener { + readonly once: boolean; + readonly fn: MessageListenerFn; + /** + * Decides which messages this listener applies to. For a listener registered + * with a maker this matches by `code`; for one registered with a predicate it + * is the predicate itself. + */ + readonly matches: (msg: IoMessage) => boolean; + /** + * Whether this is one of a host's own internal listeners (e.g. stack-activity + * routing). Internal listeners are not removed by `removeUserListeners`. + * + * @default false - a user listener registered via `on`/`once`/`rewrite`/`respond` + */ + readonly internal?: boolean; +} + +/** + * The outcome of running the registry's listeners over a single message. + */ +export interface AppliedListeners { + /** + * The (possibly rewritten) message to hand to the host's default handling. + */ + readonly message: T; + + /** + * Whether a listener asked to skip the default handling. + */ + readonly preventDefault: boolean; + + /** + * Whether a listener answered a request (its answer is folded into + * `message.defaultResponse`). + */ + readonly responded: boolean; +} + +/** + * A registry of message listeners, keyed by code or predicate, run in + * registration order. + * + * This is the shared listener engine: both the CLI's terminal host and the + * public `withListeners` wrapper own one and run their messages through it, so + * there is a single implementation of matching, ordering, rewriting, and + * request answering. A host composes a registry and does its own I/O (writing, + * prompting, telemetry) around `apply`. + */ +export class ListenerRegistry { + // Listeners in registration order. Each carries a matcher (by code, or a + // custom predicate). See `on`/`once`/`rewrite`/`respond`. + private readonly listeners: MessageListener[] = []; + + /** + * Register a listener that is invoked for every message that matches the + * selector. Returns a function that removes the listener again. + */ + public on( + selector: IoMessageMaker | IoRequestMaker | ((msg: IoMessage) => msg is IoMessage), + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void; + public on( + predicate: (msg: IoMessage) => boolean, + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void; + public on(selector: MessageSelector, listener: MessageListenerFn): () => void { + return this.add({ once: false, fn: listener, matches: messageMatcher(selector) }); + } + + /** + * Like `on`, but the listener is automatically removed after it has been + * invoked once. + */ + public once( + selector: IoMessageMaker | IoRequestMaker | ((msg: IoMessage) => msg is IoMessage), + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void; + public once( + predicate: (msg: IoMessage) => boolean, + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void; + public once(selector: MessageSelector, listener: MessageListenerFn): () => void { + return this.add({ once: true, fn: listener, matches: messageMatcher(selector) }); + } + + /** + * Register a formatter that replaces the printed text of matching messages, + * optionally also overriding the level. Syntactic sugar for an `on` listener + * that returns `{ message, level? }`. + */ + public rewrite( + code: IoMessageMaker | IoRequestMaker, + formatter: (msg: IoMessage) => string, + level?: IoMessageLevel, + ): () => void { + return this.on(code, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + } + + /** + * Like `rewrite`, but the formatter is removed after it has been applied once. + */ + public rewriteOnce( + code: IoMessageMaker | IoRequestMaker, + formatter: (msg: IoMessage) => string, + level?: IoMessageLevel, + ): () => void { + return this.once(code, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + } + + /** + * Answer a request (by its code) with a fixed value so the host does not + * prompt. Syntactic sugar for an `on` listener returning + * `{ respond: value, preventDefault: suppressQuestion }`. + * + * @param suppressQuestion - whether to also suppress surfacing the question + * text. Defaults to `true` (answer silently). + */ + public respond(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { + return this.add({ once: false, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(code) }); + } + + /** + * Like `respond`, but the answer is given only once and then removed. + */ + public respondOnce(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { + return this.add({ once: true, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(code) }); + } + + /** + * Register one of the host's own internal listeners (e.g. stack-activity + * routing). Unlike listeners added via `on`/`once`/etc., an internal listener + * survives `removeUserListeners`. Returns a function that removes it. + */ + public addInternal(matches: (msg: IoMessage) => boolean, fn: MessageListenerFn): () => void { + return this.add({ once: false, internal: true, fn, matches }); + } + + /** + * Remove every listener registered via `on`/`once`/`rewrite`/`respond`, + * keeping the host's internal listeners so the host keeps working afterwards. + */ + public removeUserListeners(): void { + // Drop user listeners in place (preserving array identity for any + // outstanding dispose closures); keep the host's internal listeners. + for (let i = this.listeners.length - 1; i >= 0; i--) { + if (!this.listeners[i].internal) { + this.listeners.splice(i, 1); + } + } + } + + /** + * Run every registered listener that matches the message, in registration + * order. A listener matches by its code (maker) or its custom predicate. + * + * A listener may update the message text/level (passed on to subsequent + * listeners and the host), prevent the default handling, or (for requests) + * answer it. `once` listeners are removed after they have run. Matching is + * decided against the message as emitted, so a rewrite by an earlier listener + * does not change which later listeners apply. + * + * Returns the (possibly updated) message, whether the default handling was + * prevented, and whether a listener answered the request (folded into the + * message's `defaultResponse`). + */ + public async apply>(msg: T): Promise> { + let current = msg; + let preventDefault = false; + let responded = false; + // Iterate over a copy so that `once` listeners can remove themselves safely. + for (const listener of [...this.listeners]) { + // Match against the emitted message; a listener receives the cumulatively + // transformed `current` message. + if (!listener.matches(msg)) { + continue; + } + + // Listeners may be async; await each one before running the next so the + // cumulative effect on the message stays order-deterministic. + const result = await listener.fn(current); + + if (listener.once) { + const index = this.listeners.indexOf(listener); + if (index >= 0) { + this.listeners.splice(index, 1); + } + } + + if (result) { + if (result.message !== undefined) { + current = { ...current, message: result.message }; + } + if (result.level !== undefined) { + current = { ...current, level: result.level }; + } + if (result.preventDefault) { + preventDefault = true; + } + // The presence of the key is what matters (so `false`/`0`/`''` are valid + // answers); `'defaultResponse' in msg` tells a request from a notification. + if ('respond' in result && 'defaultResponse' in msg) { + current = { ...current, defaultResponse: result.respond }; + responded = true; + } + } + } + + return { message: current, preventDefault, responded }; + } + + /** + * Add a listener to the registry and return a function that removes it. + */ + private add(listener: MessageListener): () => void { + this.listeners.push(listener); + + return () => { + const index = this.listeners.indexOf(listener); + if (index >= 0) { + this.listeners.splice(index, 1); + } + }; + } +} + +/** + * Convert a `MessageSelector` into a predicate that decides whether a listener + * applies to a message. A maker matches messages carrying its `code`; a + * predicate (e.g. a maker's `.is`, or any `(msg) => boolean`) is used as-is. + */ +export function messageMatcher(selector: MessageSelector): (msg: IoMessage) => boolean { + if (typeof selector === 'function') { + return selector; + } + const { code } = selector; + return (msg) => msg.code === code; +} + +/** + * Combine several selectors into a single predicate that matches a message when + * *any* of them matches. Each selector may be a maker (matched by its `code`) + * or a predicate. + * + * @example + * ```ts + * host.on(matchAny(IO.CDK_TOOLKIT_I5501, IO.CDK_TOOLKIT_I5502), listener); + * ``` + */ +export function matchAny(...selectors: MessageSelector[]): (msg: IoMessage) => boolean { + const matchers = selectors.map(messageMatcher); + return (msg) => matchers.some((matches) => matches(msg)); +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts index 4eae4b95a..644f3d940 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts @@ -4,7 +4,7 @@ import type { ActionLessMessage, ActionLessRequest } from './io-helper'; /** * Information for each IO Message Code. */ -interface CodeInfo { +export interface CodeInfo { /** * The message code. */ @@ -30,7 +30,7 @@ interface CodeInfo { /** * Information for each IO Message */ -interface MessageInfo extends CodeInfo { +export interface MessageInfo extends CodeInfo { /** * The message level */ @@ -93,7 +93,7 @@ type CodeInfoMaybeInterface = [T] extends [AbsentData] ? Omit(details: CodeInfoMaybeInterface) => message('trace', details); export const debug = (details: CodeInfoMaybeInterface) => message('debug', details); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index 08ab78f13..15c583644 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -1,5 +1,5 @@ import type * as cxapi from '@aws-cdk/cloud-assembly-api'; -import * as make from './message-maker'; +import { confirm, debug, error, info, question, result, trace, warn } from './message-maker'; import type { SpanDefinition } from './span'; import type { DiagnosedStack } from '../../../actions/diagnose'; import type { ValidateResult } from '../../../actions/validate'; @@ -41,605 +41,605 @@ import type { FileWatchEvent, WatchSettings } from '../../../payloads/watch'; */ export const IO = { // warnings & errors - CDK_TOOLKIT_W0100: make.warn({ + CDK_TOOLKIT_W0100: warn({ code: 'CDK_TOOLKIT_W0100', description: 'Credential plugin warnings', }), // 1: Synth (1xxx) - CDK_TOOLKIT_I1000: make.info({ + CDK_TOOLKIT_I1000: info({ code: 'CDK_TOOLKIT_I1000', description: 'Provides synthesis times.', interface: 'Operation', }), - CDK_TOOLKIT_I1001: make.trace({ + CDK_TOOLKIT_I1001: trace({ code: 'CDK_TOOLKIT_I1001', description: 'Cloud Assembly synthesis is starting', interface: 'StackSelectionDetails', }), - CDK_TOOLKIT_I1002: make.info({ + CDK_TOOLKIT_I1002: info({ code: 'CDK_TOOLKIT_I1002', description: 'Stacks added to the selection because they are dependencies of the selected stacks (upstream expansion)', }), - CDK_TOOLKIT_I1003: make.info({ + CDK_TOOLKIT_I1003: info({ code: 'CDK_TOOLKIT_I1003', description: 'Stacks added to the selection because they are dependent on the selected stacks (downstream expansion)', }), - CDK_TOOLKIT_I1901: make.result({ + CDK_TOOLKIT_I1901: result({ code: 'CDK_TOOLKIT_I1901', description: 'Provides stack data', interface: 'StackAndAssemblyData', }), - CDK_TOOLKIT_I1902: make.result({ + CDK_TOOLKIT_I1902: result({ code: 'CDK_TOOLKIT_I1902', description: 'Successfully deployed stacks', interface: 'AssemblyData', }), // 2: List (2xxx) - CDK_TOOLKIT_I2901: make.result({ + CDK_TOOLKIT_I2901: result({ code: 'CDK_TOOLKIT_I2901', description: 'Provides details on the selected stacks and their dependencies', interface: 'StackDetailsPayload', }), // 3: Import & Migrate - CDK_TOOLKIT_I3100: make.confirm({ + CDK_TOOLKIT_I3100: confirm({ code: 'CDK_TOOLKIT_I3100', description: 'Confirm the import of a specific resource', interface: 'ResourceImportRequest', }), - CDK_TOOLKIT_I3110: make.question({ + CDK_TOOLKIT_I3110: question({ code: 'CDK_TOOLKIT_I3110', description: 'Additional information is needed to identify a resource', interface: 'ResourceIdentificationRequest', }), - CDK_TOOLKIT_E3900: make.error({ + CDK_TOOLKIT_E3900: error({ code: 'CDK_TOOLKIT_E3900', description: 'Resource import failed', interface: 'ErrorPayload', }), // 4: Diff (40xx - 44xx) - CDK_TOOLKIT_I4000: make.trace({ + CDK_TOOLKIT_I4000: trace({ code: 'CDK_TOOLKIT_I4000', description: 'Diff stacks is starting', interface: 'StackSelectionDetails', }), - CDK_TOOLKIT_I4001: make.result({ + CDK_TOOLKIT_I4001: result({ code: 'CDK_TOOLKIT_I4001', description: 'Output of the diff command', interface: 'DiffResult', }), - CDK_TOOLKIT_I4002: make.result({ + CDK_TOOLKIT_I4002: result({ code: 'CDK_TOOLKIT_I4002', description: 'The diff for a single stack', interface: 'StackDiff', }), // 4: Drift (45xx - 49xx) - CDK_TOOLKIT_I4500: make.trace({ + CDK_TOOLKIT_I4500: trace({ code: 'CDK_TOOLKIT_I4500', description: 'Drift detection is starting', interface: 'StackSelectionDetails', }), - CDK_TOOLKIT_I4509: make.result({ + CDK_TOOLKIT_I4509: result({ code: 'CDK_TOOLKIT_I4592', description: 'Results of the drift', interface: 'Duration', }), - CDK_TOOLKIT_I4590: make.result({ + CDK_TOOLKIT_I4590: result({ code: 'CDK_TOOLKIT_I4590', description: 'Results of a stack drift', interface: 'DriftResultPayload', }), - CDK_TOOLKIT_W4591: make.warn({ + CDK_TOOLKIT_W4591: warn({ code: 'CDK_TOOLKIT_W4591', description: 'Missing drift result fort a stack.', interface: 'SingleStack', }), // 5: Deploy & Watch (5xxx) - CDK_TOOLKIT_I5000: make.info({ + CDK_TOOLKIT_I5000: info({ code: 'CDK_TOOLKIT_I5000', description: 'Provides deployment times', interface: 'Duration', }), - CDK_TOOLKIT_I5001: make.info({ + CDK_TOOLKIT_I5001: info({ code: 'CDK_TOOLKIT_I5001', description: 'Provides total time in deploy action, including synth and rollback', interface: 'Duration', }), - CDK_TOOLKIT_I5002: make.info({ + CDK_TOOLKIT_I5002: info({ code: 'CDK_TOOLKIT_I5002', description: 'Provides time for resource migration', interface: 'Duration', }), - CDK_TOOLKIT_W5021: make.warn({ + CDK_TOOLKIT_W5021: warn({ code: 'CDK_TOOLKIT_W5021', description: 'Empty non-existent stack, deployment is skipped', }), - CDK_TOOLKIT_W5022: make.warn({ + CDK_TOOLKIT_W5022: warn({ code: 'CDK_TOOLKIT_W5022', description: 'Empty existing stack, stack will be destroyed', }), - CDK_TOOLKIT_I5031: make.info({ + CDK_TOOLKIT_I5031: info({ code: 'CDK_TOOLKIT_I5031', description: 'Informs about any log groups that are traced as part of the deployment', }), - CDK_TOOLKIT_I5032: make.debug({ + CDK_TOOLKIT_I5032: debug({ code: 'CDK_TOOLKIT_I5032', description: 'Start monitoring log groups', interface: 'CloudWatchLogMonitorControlEvent', }), - CDK_TOOLKIT_I5033: make.info({ + CDK_TOOLKIT_I5033: info({ code: 'CDK_TOOLKIT_I5033', description: 'A log event received from Cloud Watch', interface: 'CloudWatchLogEvent', }), - CDK_TOOLKIT_I5034: make.debug({ + CDK_TOOLKIT_I5034: debug({ code: 'CDK_TOOLKIT_I5034', description: 'Stop monitoring log groups', interface: 'CloudWatchLogMonitorControlEvent', }), - CDK_TOOLKIT_E5035: make.error({ + CDK_TOOLKIT_E5035: error({ code: 'CDK_TOOLKIT_E5035', description: 'A log monitoring error', interface: 'ErrorPayload', }), - CDK_TOOLKIT_I5050: make.confirm({ + CDK_TOOLKIT_I5050: confirm({ code: 'CDK_TOOLKIT_I5050', description: 'Confirm rollback during deployment', interface: 'ConfirmationRequest', }), - CDK_TOOLKIT_I5060: make.confirm({ + CDK_TOOLKIT_I5060: confirm({ code: 'CDK_TOOLKIT_I5060', description: 'Confirm deploy security sensitive changes', interface: 'DeployConfirmationRequest', }), - CDK_TOOLKIT_I5100: make.info({ + CDK_TOOLKIT_I5100: info({ code: 'CDK_TOOLKIT_I5100', description: 'Stack deploy progress', interface: 'StackDeployProgress', }), // Assets (52xx) - CDK_TOOLKIT_I5210: make.trace({ + CDK_TOOLKIT_I5210: trace({ code: 'CDK_TOOLKIT_I5210', description: 'Started building a specific asset', interface: 'BuildAsset', }), - CDK_TOOLKIT_I5211: make.trace({ + CDK_TOOLKIT_I5211: trace({ code: 'CDK_TOOLKIT_I5211', description: 'Building the asset has completed', interface: 'Duration', }), - CDK_TOOLKIT_I5220: make.trace({ + CDK_TOOLKIT_I5220: trace({ code: 'CDK_TOOLKIT_I5220', description: 'Started publishing a specific asset', interface: 'PublishAsset', }), - CDK_TOOLKIT_I5221: make.trace({ + CDK_TOOLKIT_I5221: trace({ code: 'CDK_TOOLKIT_I5221', description: 'Publishing the asset has completed', interface: 'Duration', }), - CDK_ASSETS_I5270: make.info({ + CDK_ASSETS_I5270: info({ code: 'CDK_ASSETS_I5270', description: 'Publishing the asset has started', interface: 'PublishAssetEvent', }), - CDK_ASSETS_I5271: make.debug({ + CDK_ASSETS_I5271: debug({ code: 'CDK_ASSETS_I5271', description: 'Debug messaged emitted during publishing of the asset', interface: 'PublishAssetEvent', }), - CDK_ASSETS_I5275: make.info({ + CDK_ASSETS_I5275: info({ code: 'CDK_ASSETS_I5275', description: 'Publishing the asset has completed successfully', interface: 'PublishAssetEvent', }), - CDK_ASSETS_E5279: make.error({ + CDK_ASSETS_E5279: error({ code: 'CDK_ASSETS_E5279', description: 'There was an error while publishing the asset', interface: 'PublishAssetEvent', }), // Watch (53xx) - CDK_TOOLKIT_I5310: make.debug({ + CDK_TOOLKIT_I5310: debug({ code: 'CDK_TOOLKIT_I5310', description: 'The computed settings used for file watching', interface: 'WatchSettings', }), - CDK_TOOLKIT_I5311: make.info({ + CDK_TOOLKIT_I5311: info({ code: 'CDK_TOOLKIT_I5311', description: 'File watching started', interface: 'FileWatchEvent', }), - CDK_TOOLKIT_I5312: make.info({ + CDK_TOOLKIT_I5312: info({ code: 'CDK_TOOLKIT_I5312', description: 'File event detected, starting deployment', interface: 'FileWatchEvent', }), - CDK_TOOLKIT_I5313: make.info({ + CDK_TOOLKIT_I5313: info({ code: 'CDK_TOOLKIT_I5313', description: 'File event detected during active deployment, changes are queued', interface: 'FileWatchEvent', }), - CDK_TOOLKIT_I5314: make.info({ + CDK_TOOLKIT_I5314: info({ code: 'CDK_TOOLKIT_I5314', description: 'Initial watch deployment started', }), - CDK_TOOLKIT_I5315: make.info({ + CDK_TOOLKIT_I5315: info({ code: 'CDK_TOOLKIT_I5315', description: 'Queued watch deployment started', }), // Hotswap (54xx) - CDK_TOOLKIT_I5400: make.trace({ + CDK_TOOLKIT_I5400: trace({ code: 'CDK_TOOLKIT_I5400', description: 'Attempting a hotswap deployment', interface: 'HotswapDeploymentAttempt', }), - CDK_TOOLKIT_I5401: make.trace({ + CDK_TOOLKIT_I5401: trace({ code: 'CDK_TOOLKIT_I5401', description: 'Computed details for the hotswap deployment', interface: 'HotswapDeploymentDetails', }), - CDK_TOOLKIT_I5402: make.info({ + CDK_TOOLKIT_I5402: info({ code: 'CDK_TOOLKIT_I5402', description: 'A hotswappable change is processed as part of a hotswap deployment', interface: 'HotswappableChange', }), - CDK_TOOLKIT_I5403: make.info({ + CDK_TOOLKIT_I5403: info({ code: 'CDK_TOOLKIT_I5403', description: 'The hotswappable change has completed processing', interface: 'HotswappableChange', }), - CDK_TOOLKIT_I5410: make.info({ + CDK_TOOLKIT_I5410: info({ code: 'CDK_TOOLKIT_I5410', description: 'Hotswap deployment has ended, a full deployment might still follow if needed', interface: 'HotswapResult', }), // Stack Monitor (55xx) - CDK_TOOLKIT_I5501: make.info({ + CDK_TOOLKIT_I5501: info({ code: 'CDK_TOOLKIT_I5501', description: 'Stack Monitoring: Start monitoring of a single stack', interface: 'StackMonitoringControlEvent', }), - CDK_TOOLKIT_I5502: make.info({ + CDK_TOOLKIT_I5502: info({ code: 'CDK_TOOLKIT_I5502', description: 'Stack Monitoring: Activity event for a single stack', interface: 'StackActivity', }), - CDK_TOOLKIT_I5503: make.info({ + CDK_TOOLKIT_I5503: info({ code: 'CDK_TOOLKIT_I5503', description: 'Stack Monitoring: Finished monitoring of a single stack', interface: 'StackMonitoringControlEvent', }), // Success (59xx) - CDK_TOOLKIT_I5900: make.result({ + CDK_TOOLKIT_I5900: result({ code: 'CDK_TOOLKIT_I5900', description: 'Deployment results on success', interface: 'SuccessfulDeployStackResult', }), - CDK_TOOLKIT_I5901: make.info({ + CDK_TOOLKIT_I5901: info({ code: 'CDK_TOOLKIT_I5901', description: 'Generic deployment success messages', }), - CDK_TOOLKIT_W5400: make.warn({ + CDK_TOOLKIT_W5400: warn({ code: 'CDK_TOOLKIT_W5400', description: 'Hotswap disclosure message', }), - CDK_TOOLKIT_E5001: make.error({ + CDK_TOOLKIT_E5001: error({ code: 'CDK_TOOLKIT_E5001', description: 'No stacks found', }), - CDK_TOOLKIT_E5500: make.error({ + CDK_TOOLKIT_E5500: error({ code: 'CDK_TOOLKIT_E5500', description: 'Stack Monitoring error', interface: 'ErrorPayload', }), // 6: Rollback (6xxx) - CDK_TOOLKIT_I6000: make.info({ + CDK_TOOLKIT_I6000: info({ code: 'CDK_TOOLKIT_I6000', description: 'Provides rollback times', interface: 'Duration', }), - CDK_TOOLKIT_I6100: make.info({ + CDK_TOOLKIT_I6100: info({ code: 'CDK_TOOLKIT_I6100', description: 'Stack rollback progress', interface: 'StackRollbackProgress', }), - CDK_TOOLKIT_E6001: make.error({ + CDK_TOOLKIT_E6001: error({ code: 'CDK_TOOLKIT_E6001', description: 'No stacks found', }), - CDK_TOOLKIT_E6900: make.error({ + CDK_TOOLKIT_E6900: error({ code: 'CDK_TOOLKIT_E6900', description: 'Rollback failed', interface: 'ErrorPayload', }), // 7: Destroy (7xxx) - CDK_TOOLKIT_I7000: make.info({ + CDK_TOOLKIT_I7000: info({ code: 'CDK_TOOLKIT_I7000', description: 'Provides destroy times', interface: 'Duration', }), - CDK_TOOLKIT_I7001: make.trace({ + CDK_TOOLKIT_I7001: trace({ code: 'CDK_TOOLKIT_I7001', description: 'Provides destroy time for a single stack', interface: 'Duration', }), - CDK_TOOLKIT_I7010: make.confirm({ + CDK_TOOLKIT_I7010: confirm({ code: 'CDK_TOOLKIT_I7010', description: 'Confirm destroy stacks', interface: 'ConfirmationRequest', }), - CDK_TOOLKIT_I7100: make.info({ + CDK_TOOLKIT_I7100: info({ code: 'CDK_TOOLKIT_I7100', description: 'Stack destroy progress', interface: 'StackDestroyProgress', }), - CDK_TOOLKIT_I7101: make.trace({ + CDK_TOOLKIT_I7101: trace({ code: 'CDK_TOOLKIT_I7101', description: 'Start stack destroying', interface: 'StackDestroy', }), - CDK_TOOLKIT_I7900: make.result({ + CDK_TOOLKIT_I7900: result({ code: 'CDK_TOOLKIT_I7900', description: 'Stack deletion succeeded', interface: 'cxapi.CloudFormationStackArtifact', }), - CDK_TOOLKIT_E7010: make.error({ + CDK_TOOLKIT_E7010: error({ code: 'CDK_TOOLKIT_E7010', description: 'Action was aborted due to negative confirmation of request', }), - CDK_TOOLKIT_E7900: make.error({ + CDK_TOOLKIT_E7900: error({ code: 'CDK_TOOLKIT_E7900', description: 'Stack deletion failed', interface: 'ErrorPayload', }), // 8. Refactor (8xxx) - CDK_TOOLKIT_E8900: make.error({ + CDK_TOOLKIT_E8900: error({ code: 'CDK_TOOLKIT_E8900', description: 'Stack refactor failed', interface: 'ErrorPayload', }), - CDK_TOOLKIT_I8900: make.result({ + CDK_TOOLKIT_I8900: result({ code: 'CDK_TOOLKIT_I8900', description: 'Refactor result', interface: 'RefactorResult', }), - CDK_TOOLKIT_I8910: make.confirm({ + CDK_TOOLKIT_I8910: confirm({ code: 'CDK_TOOLKIT_I8910', description: 'Confirm refactor', interface: 'ConfirmationRequest', }), - CDK_TOOLKIT_W8010: make.warn({ + CDK_TOOLKIT_W8010: warn({ code: 'CDK_TOOLKIT_W8010', description: 'Refactor execution not yet supported', }), // Orphan (88xx) - CDK_TOOLKIT_I8810: make.confirm({ + CDK_TOOLKIT_I8810: confirm({ code: 'CDK_TOOLKIT_I8810', description: 'Confirm orphan resources', interface: 'ConfirmationRequest', }), // 9: Bootstrap, gc, flags & publish (9xxx) - CDK_TOOLKIT_I9000: make.info({ + CDK_TOOLKIT_I9000: info({ code: 'CDK_TOOLKIT_I9000', description: 'Provides bootstrap times', interface: 'Duration', }), - CDK_TOOLKIT_I9100: make.info({ + CDK_TOOLKIT_I9100: info({ code: 'CDK_TOOLKIT_I9100', description: 'Bootstrap progress', interface: 'BootstrapEnvironmentProgress', }), // gc (92xx) - CDK_TOOLKIT_I9210: make.question({ + CDK_TOOLKIT_I9210: question({ code: 'CDK_TOOLKIT_I9210', description: 'Confirm the deletion of a batch of assets', interface: 'AssetBatchDeletionRequest', }), - CDK_TOOLKIT_I9900: make.result<{ environment: cxapi.Environment }>({ + CDK_TOOLKIT_I9900: result<{ environment: cxapi.Environment }>({ code: 'CDK_TOOLKIT_I9900', description: 'Bootstrap results on success', interface: 'cxapi.Environment', }), - CDK_TOOLKIT_E9900: make.error({ + CDK_TOOLKIT_E9900: error({ code: 'CDK_TOOLKIT_E9900', description: 'Bootstrap failed', interface: 'ErrorPayload', }), // flags (93xx) - CDK_TOOLKIT_I9300: make.info({ + CDK_TOOLKIT_I9300: info({ code: 'CDK_TOOLKIT_I9300', description: 'Confirm the feature flag configuration changes', interface: 'FeatureFlagChangeRequest', }), // publish (94xx) - CDK_TOOLKIT_I9400: make.info({ + CDK_TOOLKIT_I9400: info({ code: 'CDK_TOOLKIT_I9400', description: 'All assets are already published', }), - CDK_TOOLKIT_I9401: make.info({ + CDK_TOOLKIT_I9401: info({ code: 'CDK_TOOLKIT_I9401', description: 'Publishing assets', interface: 'AssetsPayload', }), - CDK_TOOLKIT_I9402: make.result({ + CDK_TOOLKIT_I9402: result({ code: 'CDK_TOOLKIT_I9402', description: 'Publish assets results on success', interface: 'AssetsPayload', }), // diagnose (95xx) - CDK_TOOLKIT_I9500: make.info({ + CDK_TOOLKIT_I9500: info({ code: 'CDK_TOOLKIT_I9500', description: 'Stack diagnosis (no problems found)', interface: 'DiagnosedStack', }), - CDK_TOOLKIT_E9500: make.error({ + CDK_TOOLKIT_E9500: error({ code: 'CDK_TOOLKIT_E9500', description: 'Stack diagnosis (problems found)', interface: 'DiagnosedStack', }), - CDK_TOOLKIT_W9501: make.warn({ + CDK_TOOLKIT_W9501: warn({ code: 'CDK_TOOLKIT_W9501', description: 'Stack diagnosis (diagnosis could not be performed)', interface: 'DiagnosedStack', }), // validate (96xx) - CDK_TOOLKIT_I9600: make.info({ + CDK_TOOLKIT_I9600: info({ code: 'CDK_TOOLKIT_I9600', description: 'Validation did not find any problems', interface: 'ValidateResult', }), - CDK_TOOLKIT_E9600: make.error({ + CDK_TOOLKIT_E9600: error({ code: 'CDK_TOOLKIT_E9600', description: 'Policy validation failed', interface: 'ValidateResult', }), - CDK_TOOLKIT_I9601: make.info({ + CDK_TOOLKIT_I9601: info({ code: 'CDK_TOOLKIT_I9601', description: 'No policy validation report found', }), - CDK_TOOLKIT_W9602: make.warn({ + CDK_TOOLKIT_W9602: warn({ code: 'CDK_TOOLKIT_W9602', description: 'Online validation could not be completed for a stack', }), // Notices - CDK_TOOLKIT_I0100: make.info({ + CDK_TOOLKIT_I0100: info({ code: 'CDK_TOOLKIT_I0100', description: 'Notices decoration (the header or footer of a list of notices)', }), - CDK_TOOLKIT_W0101: make.warn({ + CDK_TOOLKIT_W0101: warn({ code: 'CDK_TOOLKIT_W0101', description: 'A notice that is marked as a warning', }), - CDK_TOOLKIT_E0101: make.error({ + CDK_TOOLKIT_E0101: error({ code: 'CDK_TOOLKIT_E0101', description: 'A notice that is marked as an error', }), - CDK_TOOLKIT_I0101: make.info({ + CDK_TOOLKIT_I0101: info({ code: 'CDK_TOOLKIT_I0101', description: 'A notice that is marked as informational', }), // Assembly codes - CDK_ASSEMBLY_I0010: make.debug({ + CDK_ASSEMBLY_I0010: debug({ code: 'CDK_ASSEMBLY_I0010', description: 'Generic environment preparation debug messages', }), - CDK_ASSEMBLY_W0010: make.warn({ + CDK_ASSEMBLY_W0010: warn({ code: 'CDK_ASSEMBLY_W0010', description: 'Emitted if the found framework version does not support context overflow', }), - CDK_ASSEMBLY_I0042: make.debug({ + CDK_ASSEMBLY_I0042: debug({ code: 'CDK_ASSEMBLY_I0042', description: 'Writing context updates', interface: 'UpdatedContext', }), - CDK_ASSEMBLY_I0240: make.debug({ + CDK_ASSEMBLY_I0240: debug({ code: 'CDK_ASSEMBLY_I0240', description: 'Context lookup was stopped as no further progress was made. ', interface: 'MissingContext', }), - CDK_ASSEMBLY_I0241: make.debug({ + CDK_ASSEMBLY_I0241: debug({ code: 'CDK_ASSEMBLY_I0241', description: 'Fetching missing context. This is an iterative message that may appear multiple times with different missing keys.', interface: 'MissingContext', }), - CDK_ASSEMBLY_I1000: make.debug({ + CDK_ASSEMBLY_I1000: debug({ code: 'CDK_ASSEMBLY_I1000', description: 'Cloud assembly output starts', }), - CDK_ASSEMBLY_I1001: make.info({ + CDK_ASSEMBLY_I1001: info({ code: 'CDK_ASSEMBLY_I1001', description: 'Output lines emitted by the cloud assembly to stdout', }), - CDK_ASSEMBLY_E1002: make.error({ + CDK_ASSEMBLY_E1002: error({ code: 'CDK_ASSEMBLY_E1002', description: 'Output lines emitted by the cloud assembly to stderr', }), - CDK_ASSEMBLY_I1003: make.info({ + CDK_ASSEMBLY_I1003: info({ code: 'CDK_ASSEMBLY_I1003', description: 'Cloud assembly output finished', }), - CDK_ASSEMBLY_E1111: make.error({ + CDK_ASSEMBLY_E1111: error({ code: 'CDK_ASSEMBLY_E1111', description: 'Incompatible CDK CLI version. Upgrade needed.', interface: 'ErrorPayload', }), - CDK_ASSEMBLY_I0150: make.debug({ + CDK_ASSEMBLY_I0150: debug({ code: 'CDK_ASSEMBLY_I0150', description: 'Indicates the use of a pre-synthesized cloud assembly directory', }), - CDK_ASSEMBLY_I0300: make.info({ + CDK_ASSEMBLY_I0300: info({ code: 'CDK_ASSEMBLY_I0300', description: 'An info message emitted by a Context Provider', interface: 'ContextProviderMessageSource', }), - CDK_ASSEMBLY_I0301: make.debug({ + CDK_ASSEMBLY_I0301: debug({ code: 'CDK_ASSEMBLY_I0301', description: 'A debug message emitted by a Context Provider', interface: 'ContextProviderMessageSource', }), // Assembly Annotations - CDK_ASSEMBLY_I9999: make.info({ + CDK_ASSEMBLY_I9999: info({ code: 'CDK_ASSEMBLY_I9999', description: 'Annotations emitted by the cloud assembly', interface: 'cxapi.SynthesisMessage', }), - CDK_ASSEMBLY_W9999: make.warn({ + CDK_ASSEMBLY_W9999: warn({ code: 'CDK_ASSEMBLY_W9999', description: 'Warnings emitted by the cloud assembly', interface: 'cxapi.SynthesisMessage', }), - CDK_ASSEMBLY_E9999: make.error({ + CDK_ASSEMBLY_E9999: error({ code: 'CDK_ASSEMBLY_E9999', description: 'Errors emitted by the cloud assembly', interface: 'cxapi.SynthesisMessage', }), // SDK codes - CDK_SDK_I0100: make.trace({ + CDK_SDK_I0100: trace({ code: 'CDK_SDK_I0100', description: 'An SDK trace. SDK traces are emitted as traces to the IoHost, but contain the original SDK logging level.', interface: 'SdkTrace', }), - CDK_SDK_I1100: make.question({ + CDK_SDK_I1100: question({ code: 'CDK_SDK_I1100', description: 'Get an MFA token for an MFA device.', interface: 'MfaTokenRequest', diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts index 1e3da5efe..fdcc11918 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts @@ -19,3 +19,4 @@ export * from './logs-monitor'; export * from './hotswap'; export * from './gc'; export * from './import'; +export * from './flags'; diff --git a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts new file mode 100644 index 000000000..ab4637ce1 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts @@ -0,0 +1,340 @@ +import type { IIoHost, IoMessage, IoRequest } from '../../../lib/api/io'; +import { matchAny, withListeners, IO } from '../../../lib/api/io'; + +/** + * A minimal `IIoHost` that records what it is asked to handle, so we can assert + * on what a wrapped host forwards to it (after listeners ran). + */ +class RecordingIoHost implements IIoHost { + public readonly notified: Array> = []; + public readonly requested: Array> = []; + + /** Response the inner host resolves a request with, standing in for a prompt. */ + public prompted: any = 'PROMPTED'; + + public async notify(msg: IoMessage): Promise { + this.notified.push(msg); + } + + public async requestResponse(msg: IoRequest): Promise { + this.requested.push(msg); + return this.prompted; + } +} + +const I2901 = IO.CDK_TOOLKIT_I2901; // list result, payload has `stacks` +const I7010 = IO.CDK_TOOLKIT_I7010; // destroy confirmation request (boolean) + +function notification(over: Partial> = {}): IoMessage { + return { + time: new Date('2024-01-01T12:00:00'), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I2901', + message: 'the original text', + data: { stacks: [] }, + ...over, + }; +} + +function request(over: Partial> = {}): IoRequest { + return { + time: new Date('2024-01-01T12:00:00'), + level: 'info', + action: 'destroy', + code: 'CDK_TOOLKIT_I7010', + message: 'Are you sure?', + data: {}, + defaultResponse: true, + ...over, + }; +} + +describe('withListeners', () => { + let inner: RecordingIoHost; + + beforeEach(() => { + inner = new RecordingIoHost(); + }); + + test('the wrapped host is still an IIoHost that forwards to the inner host', async () => { + const host = withListeners(inner); + const msg = notification(); + + await host.notify(msg); + + expect(inner.notified).toEqual([msg]); + }); + + describe('on', () => { + test('runs the listener for a matching code and forwards the message', async () => { + const host = withListeners(inner); + const seen: Array<{ stacks: unknown[] }> = []; + host.on(I2901, (m) => { + seen.push(m.data); + }); + + await host.notify(notification()); + + expect(seen).toHaveLength(1); + expect(inner.notified).toHaveLength(1); + }); + + test('does not run the listener for a non-matching code', async () => { + const host = withListeners(inner); + const fn = jest.fn(); + host.on(I2901, fn); + + await host.notify(notification({ code: 'CDK_TOOLKIT_I0001' })); + + expect(fn).not.toHaveBeenCalled(); + expect(inner.notified).toHaveLength(1); + }); + + test('awaits async listeners before forwarding', async () => { + const host = withListeners(inner); + const order: string[] = []; + host.on(I2901, async () => { + await new Promise((r) => setTimeout(r, 5)); + order.push('listener'); + }); + + await host.notify(notification()); + order.push('forwarded'); + + expect(order).toEqual(['listener', 'forwarded']); + }); + + test('runs matching listeners in registration order', async () => { + const host = withListeners(inner); + const order: number[] = []; + host.on(I2901, () => { + order.push(1); + }); + host.on(I2901, () => { + order.push(2); + }); + host.on(I2901, () => { + order.push(3); + }); + + await host.notify(notification()); + + expect(order).toEqual([1, 2, 3]); + }); + + test('the disposer removes the listener', async () => { + const host = withListeners(inner); + const fn = jest.fn(); + const dispose = host.on(I2901, fn); + + await host.notify(notification()); + dispose(); + await host.notify(notification()); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('matches on a predicate selector', async () => { + const host = withListeners(inner); + const fn = jest.fn(); + host.on((m) => m.level === 'warn', fn); + + await host.notify(notification({ level: 'warn' })); + await host.notify(notification({ level: 'info' })); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + test("matches on a maker's .is type guard", async () => { + const host = withListeners(inner); + const fn = jest.fn(); + host.on(I2901.is, fn); + + await host.notify(notification()); + await host.notify(notification({ code: 'CDK_TOOLKIT_I0001' })); + + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('once', () => { + test('runs only for the first matching message', async () => { + const host = withListeners(inner); + const fn = jest.fn(); + host.once(I2901, fn); + + await host.notify(notification()); + await host.notify(notification()); + + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('rewrite', () => { + test('replaces the forwarded message text, leaving the code intact', async () => { + const host = withListeners(inner); + host.rewrite(I2901, (m) => `rewritten: ${m.data.stacks.length}`); + + await host.notify(notification()); + + expect(inner.notified[0].message).toBe('rewritten: 0'); + expect(inner.notified[0].code).toBe('CDK_TOOLKIT_I2901'); + }); + + test('can also override the level', async () => { + const host = withListeners(inner); + host.rewrite(I2901, (m) => m.message, 'debug'); + + await host.notify(notification()); + + expect(inner.notified[0].level).toBe('debug'); + }); + + test('does not mutate the caller-provided message', async () => { + const host = withListeners(inner); + host.rewrite(I2901, () => 'changed'); + const msg = notification(); + + await host.notify(msg); + + expect(msg.message).toBe('the original text'); + }); + + test('rewriteOnce applies only once', async () => { + const host = withListeners(inner); + host.rewriteOnce(I2901, () => 'changed'); + + await host.notify(notification()); + await host.notify(notification()); + + expect(inner.notified[0].message).toBe('changed'); + expect(inner.notified[1].message).toBe('the original text'); + }); + + test('rewrites accumulate across listeners', async () => { + const host = withListeners(inner); + host.rewrite(I2901, (m) => `${m.message}-a`); + host.rewrite(I2901, (m) => `${m.message}-b`); + + await host.notify(notification()); + + expect(inner.notified[0].message).toBe('the original text-a-b'); + }); + + test('matching is decided against the emitted message, not an earlier rewrite', async () => { + const host = withListeners(inner); + // First listener rewrites the text; a later predicate that keys on the old + // text must still fire, because matching sees the emitted message. + host.rewrite(I2901, () => 'changed'); + const fn = jest.fn(); + host.on((m) => m.message === 'the original text', fn); + + await host.notify(notification()); + + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('preventDefault', () => { + test('suppresses the forward to the inner host', async () => { + const host = withListeners(inner); + host.on(I2901, () => ({ preventDefault: true })); + + await host.notify(notification()); + + expect(inner.notified).toHaveLength(0); + }); + }); + + describe('requestResponse', () => { + test('forwards to the inner host when no listener answers', async () => { + const host = withListeners(inner); + inner.prompted = false; + + const answer = await host.requestResponse(request()); + + expect(inner.requested).toHaveLength(1); + expect(answer).toBe(false); + }); + + test('respond answers without asking the inner host, suppressing the question', async () => { + const host = withListeners(inner); + host.respond(I7010, true); + + const answer = await host.requestResponse(request({ defaultResponse: false })); + + expect(answer).toBe(true); + expect(inner.requested).toHaveLength(0); + expect(inner.notified).toHaveLength(0); + }); + + test('respond with suppressQuestion=false surfaces the question but still answers', async () => { + const host = withListeners(inner); + host.respond(I7010, true, false); + + const answer = await host.requestResponse(request({ defaultResponse: false })); + + expect(answer).toBe(true); + // The inner host is asked to show the question (as a notification), not to prompt. + expect(inner.notified).toHaveLength(1); + expect(inner.requested).toHaveLength(0); + }); + + test('respond treats presence of the value as the answer, so false is a valid answer', async () => { + const host = withListeners(inner); + host.respond(I7010, false); + + const answer = await host.requestResponse(request({ defaultResponse: true })); + + expect(answer).toBe(false); + expect(inner.requested).toHaveLength(0); + }); + + test('respondOnce answers only the first request', async () => { + const host = withListeners(inner); + inner.prompted = 'PROMPTED'; + host.respondOnce(I7010, false); + + const first = await host.requestResponse(request({ defaultResponse: 'default' })); + const second = await host.requestResponse(request({ defaultResponse: 'default' })); + + expect(first).toBe(false); + expect(second).toBe('PROMPTED'); + expect(inner.requested).toHaveLength(1); + }); + + test('a listener can reword a prompt before it reaches the inner host', async () => { + const host = withListeners(inner); + host.rewrite(I7010, () => 'reworded question'); + + await host.requestResponse(request()); + + expect(inner.requested[0].message).toBe('reworded question'); + }); + + test('preventDefault on a request resolves with the default response without asking', async () => { + const host = withListeners(inner); + host.on(I7010, () => ({ preventDefault: true })); + + const answer = await host.requestResponse(request({ defaultResponse: 'the-default' })); + + expect(answer).toBe('the-default'); + expect(inner.requested).toHaveLength(0); + }); + }); + + describe('matchAny', () => { + test('one listener fires for any of several codes', async () => { + const host = withListeners(inner); + const fn = jest.fn(); + host.on(matchAny(IO.CDK_TOOLKIT_I5501, IO.CDK_TOOLKIT_I5502, IO.CDK_TOOLKIT_I5503), fn); + + await host.notify(notification({ code: 'CDK_TOOLKIT_I5502' })); + await host.notify(notification({ code: 'CDK_TOOLKIT_I5503' })); + await host.notify(notification({ code: 'CDK_TOOLKIT_I2901' })); + + expect(fn).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts b/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts index 74b2a2121..dc123f004 100644 --- a/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts +++ b/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts @@ -5,8 +5,8 @@ import { ToolkitError } from '@aws-cdk/toolkit-lib'; import type { HotswapResult, IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest, ToolkitAction } from '@aws-cdk/toolkit-lib'; import chalk from 'chalk'; import * as promptly from 'promptly'; -import type { IoHelper, ActivityPrinterProps, IActivityPrinter, IoMessageMaker, IoRequestMaker, IoDefaultMessages } from '../../../lib/api-private'; -import { asIoHelper, IO, isMessageRelevantForLevel, CurrentActivityPrinter, HistoryActivityPrinter } from '../../../lib/api-private'; +import type { IoHelper, ActivityPrinterProps, IActivityPrinter, IoMessageMaker, IoRequestMaker, IoDefaultMessages, MessageListenerResult, MessageListenerResultOrPromise, MessageSelector } from '../../../lib/api-private'; +import { asIoHelper, IO, isMessageRelevantForLevel, CurrentActivityPrinter, HistoryActivityPrinter, ListenerRegistry, matchAny } from '../../../lib/api-private'; import type { Context } from '../../api/context'; import { StackActivityProgress } from '../../commands/deploy'; import { canCollectTelemetry } from '../telemetry/collect-telemetry'; @@ -22,6 +22,8 @@ import type { ITelemetrySink } from '../telemetry/sink/sink-interface'; import { isCI } from '../util/ci'; export type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest }; +export { matchAny }; +export type { MessageListenerResult, MessageListenerResultOrPromise, MessageSelector } from '../../../lib/api-private'; /** * The current action being performed by the CLI. 'none' represents the absence of an action. @@ -103,105 +105,6 @@ export interface CliIoHostProps { */ export type TargetStream = 'stdout' | 'stderr' | 'drop'; -/** - * The result a message listener may return to influence how a message is handled. - * - * A listener may update the message _text_ and/or its _level_; it cannot change - * any other field of the message (such as its `code`), which keeps the - * code-keyed listener registry valid. - */ -export interface MessageListenerResult { - /** - * Replace the text that is printed for this message. - * - * @default - the message text is left unchanged - */ - readonly message?: string; - - /** - * Override the level of this message. - * - * The new level is used for both verbosity filtering and stream selection, so - * this can move a message between stdout/stderr (e.g. downgrade a `result` to - * `info`). The `code` is intentionally left unchanged. - * - * @default - the message level is left unchanged - */ - readonly level?: IoMessageLevel; - - /** - * Skip the default handling of the message. - * - * For a notification this means it is not written to a stream. For a request - * it stops processing entirely: the user is not prompted, nothing is written, - * and the request resolves with its (possibly `respond`-overridden) default - * response. - * - * @default false - */ - readonly preventDefault?: boolean; - - /** - * For requests only: the value to resolve the request with. It is folded into - * the request's default response and skips the prompt (the request is treated - * as not promptable). The question is still written unless `preventDefault` is - * also set. Ignored for plain notifications. - * - * The presence of the key is what matters, so `false`/`0`/`''` are valid - * answers. Use the `respond`/`respondOnce` helpers for the common case. - * - * @default - this listener does not supply a response - */ - readonly respond?: unknown; -} - -/** - * What a message listener may return: nothing, a `MessageListenerResult`, or a - * `Promise` of either. - * - * Listeners may be async. The host awaits each listener before running the - * next, so registration order — and the cumulative effect on the message — is - * preserved regardless of whether listeners are sync or async. - */ -export type MessageListenerResultOrPromise = void | MessageListenerResult | Promise; - -/** - * A registered message listener. Its return value (if any) may update the - * message text and/or prevent the default processing. It may be async. - */ -type MessageListenerFn = (msg: IoMessage) => MessageListenerResultOrPromise; -interface MessageListener { - readonly once: boolean; - readonly fn: MessageListenerFn; - /** - * Decides which messages this listener applies to. For a listener registered - * with a maker this matches by `code`; for one registered with a predicate it - * is the predicate itself. - */ - readonly matches: (msg: IoMessage) => boolean; - /** - * Whether this is one of the host's own internal listeners (e.g. stack-activity - * routing). Internal listeners are not removed by `removeAllListeners`. - * - * @default false - a user listener registered via `on`/`once`/`rewrite`/`respond` - */ - readonly internal?: boolean; -} - -/** - * Selects which messages a listener applies to. - * - * Either a message/request *maker* — the listener fires for messages with that - * maker's `code` (the original behavior) — or a custom *predicate* over the - * message. A maker's `.is` type guard (e.g. `IO.CDK_TOOLKIT_I7010.is`) is a - * convenient predicate, but any `(msg) => boolean` works (e.g. to match a family - * of codes, or on the message level). - */ -export type MessageSelector = - | IoMessageMaker - | IoRequestMaker - | ((msg: IoMessage) => boolean); - /** * How an IoHost processed a single message or request. * @@ -330,9 +233,10 @@ export class CliIoHost implements IIoHost, ObservableIoHost { private corkedCounter = 0; private readonly corkedLoggingBuffer: IoMessage[] = []; - // Message listeners in registration order. Each carries a matcher (by code, - // or a custom predicate). See `on`/`once`/`rewrite`/`respond`. - private readonly messageListeners: MessageListener[] = []; + // 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(); // Observers of how messages are handled (see ObservableIoHost / observeMessages). private readonly messageObservers = new Set<(observation: IoMessageObservation) => void>(); @@ -525,8 +429,8 @@ export class CliIoHost implements IIoHost, ObservableIoHost { predicate: (msg: IoMessage) => boolean, listener: (msg: IoMessage) => MessageListenerResultOrPromise, ): () => void; - public on(selector: MessageSelector, listener: MessageListenerFn): () => void { - return this.addMessageListener({ once: false, fn: listener, matches: messageMatcher(selector) }); + public on(selector: MessageSelector, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { + return this.registry.on(selector as any, listener as any); } /** @@ -557,8 +461,8 @@ export class CliIoHost implements IIoHost, ObservableIoHost { predicate: (msg: IoMessage) => boolean, listener: (msg: IoMessage) => MessageListenerResultOrPromise, ): () => void; - public once(selector: MessageSelector, listener: MessageListenerFn): () => void { - return this.addMessageListener({ once: true, fn: listener, matches: messageMatcher(selector) }); + public once(selector: MessageSelector, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { + return this.registry.once(selector as any, listener as any); } /** @@ -573,13 +477,7 @@ export class CliIoHost implements IIoHost, ObservableIoHost { * leak into the next test). */ public removeAllListeners(): void { - // Drop user listeners in place (preserving array identity for any - // outstanding dispose closures); keep the host's internal listeners. - for (let i = this.messageListeners.length - 1; i >= 0; i--) { - if (!this.messageListeners[i].internal) { - this.messageListeners.splice(i, 1); - } - } + this.registry.removeUserListeners(); } /** @@ -598,14 +496,14 @@ export class CliIoHost implements IIoHost, ObservableIoHost { * const dispose = ioHost.respond(IO.CDK_TOOLKIT_I7010, true); */ public respond(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { - return this.addMessageListener({ once: false, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(code) }); + return this.registry.respond(code, value, suppressQuestion); } /** * Like `respond`, but the answer is given only once and then removed. */ public respondOnce(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { - return this.addMessageListener({ once: true, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(code) }); + return this.registry.respondOnce(code, value, suppressQuestion); } /** @@ -629,7 +527,7 @@ export class CliIoHost implements IIoHost, ObservableIoHost { formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void { - return this.on(code, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + return this.registry.rewrite(code, formatter, level); } /** @@ -641,83 +539,7 @@ export class CliIoHost implements IIoHost, ObservableIoHost { formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void { - return this.once(code, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); - } - - /** - * Add a listener to the registry and return a function that removes it. - */ - private addMessageListener(listener: MessageListener): () => void { - this.messageListeners.push(listener); - - return () => { - const index = this.messageListeners.indexOf(listener); - if (index >= 0) { - this.messageListeners.splice(index, 1); - } - }; - } - - /** - * Run every registered listener that matches the message, in registration - * order. A listener matches by its code (maker) or its custom predicate. - * - * A listener may update the message text/level (passed on to subsequent - * listeners and the rest of the pipeline), prevent the default processing, or - * (for requests) answer it. `once` listeners are removed after they have run. - * Matching is decided against the message as emitted, so a rewrite by an - * earlier listener does not change which later listeners apply. - * - * Returns the (possibly updated) message, whether the default processing was - * prevented, and whether a listener answered the request (and with what). - */ - private async applyMessageListeners>(msg: T): Promise<{ - message: T; - preventDefault: boolean; - responded: boolean; - }> { - let current = msg; - let preventDefault = false; - let responded = false; - // Iterate over a copy so that `once` listeners can remove themselves safely. - for (const listener of [...this.messageListeners]) { - // Match against the emitted message; a listener receives the cumulatively - // transformed `current` message. - if (!listener.matches(msg)) { - continue; - } - - // Listeners may be async; await each one before running the next so the - // cumulative effect on the message stays order-deterministic. - const result = await listener.fn(current); - - if (listener.once) { - const index = this.messageListeners.indexOf(listener); - if (index >= 0) { - this.messageListeners.splice(index, 1); - } - } - - if (result) { - if (result.message !== undefined) { - current = { ...current, message: result.message }; - } - if (result.level !== undefined) { - current = { ...current, level: result.level }; - } - if (result.preventDefault) { - preventDefault = true; - } - if ('respond' in result && 'defaultResponse' in msg) { - // Fold the answer into the request's default response and mark it - // answered, so we skip prompting and resolve with this value. - current = { ...current, defaultResponse: result.respond }; - responded = true; - } - } - } - - return { message: current, preventDefault, responded }; + return this.registry.rewriteOnce(code, formatter, level); } /** @@ -730,7 +552,7 @@ export class CliIoHost implements IIoHost, ObservableIoHost { // Run any registered listeners. A listener may update the message text // and/or prevent the default processing (e.g. stack-activity messages are // routed to the activity printer and not written to a stream). - const { message, preventDefault } = await this.applyMessageListeners(msg); + const { message, preventDefault } = await this.registry.apply(msg); // Tell observers how this message was handled (its effective form and // whether it was dropped). Skipped while replaying corked messages so each @@ -810,12 +632,10 @@ export class CliIoHost implements IIoHost, ObservableIoHost { // A single internal listener (so it survives `removeAllListeners()` and the // host keeps routing stack activity) matching any of the activity codes. - this.addMessageListener({ - once: false, - internal: true, - fn: route, - matches: matchAny(IO.CDK_TOOLKIT_I5501, IO.CDK_TOOLKIT_I5502, IO.CDK_TOOLKIT_I5503), - }); + this.registry.addInternal( + matchAny(IO.CDK_TOOLKIT_I5501, IO.CDK_TOOLKIT_I5502, IO.CDK_TOOLKIT_I5503), + route, + ); } /** @@ -884,7 +704,7 @@ export class CliIoHost implements IIoHost, ObservableIoHost { public async requestResponse(msg: IoRequest): Promise { // Listeners run exactly once here (so we don't go back through `notify`): // they may answer the request, or reword/relevel the question shown below. - const { message, ...listenerResult } = await this.applyMessageListeners(msg); + const { message, ...listenerResult } = await this.registry.apply(msg); const response = await this.resolveRequest(message, listenerResult); @@ -1036,32 +856,6 @@ export class CliIoHost implements IIoHost, ObservableIoHost { } } -/** - * Convert a `MessageSelector` into a predicate that decides whether a listener - * applies to a message. A maker matches messages carrying its `code`; a - * predicate (e.g. a maker's `.is`, or any `(msg) => boolean`) is used as-is. - */ -function messageMatcher(selector: MessageSelector): (msg: IoMessage) => boolean { - if (typeof selector === 'function') { - return selector; - } - const { code } = selector; - return (msg) => msg.code === code; -} - -/** - * Combine several selectors into a single predicate that matches a message when - * *any* of them matches. Each selector may be a maker (matched by its `code`) - * or a predicate. - * - * Useful for one listener that spans multiple codes, e.g. - * `ioHost.on(matchAny(IO.CDK_TOOLKIT_I5501, IO.CDK_TOOLKIT_I5502), listener)`. - */ -export function matchAny(...selectors: MessageSelector[]): (msg: IoMessage) => boolean { - const matchers = selectors.map(messageMatcher); - return (msg) => matchers.some((matches) => matches(msg)); -} - /** * This IoHost implementation considers a request promptable, if: * - it's a yes/no confirmation From bca91629f7bc52142a294498288d4ba84eedebdb Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Tue, 7 Jul 2026 14:22:05 -0400 Subject: [PATCH 02/13] chore: keep AbsentData off public API --- packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts | 2 +- .../toolkit-lib/lib/api/io/private/message-maker.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts index f88697ffc..2871fcf2a 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts @@ -4,5 +4,5 @@ export * from './toolkit-action'; export * from './listeners'; export { IO } from './private/messages'; -export type { IoMessageMaker, IoRequestMaker, MessageInfo, CodeInfo, AbsentData } from './private/message-maker'; +export type { IoMessageMaker, IoRequestMaker, MessageInfo, CodeInfo } from './private/message-maker'; export type { ActionLessMessage, ActionLessRequest } from './private/io-helper'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts index 644f3d940..f51805434 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts @@ -44,7 +44,7 @@ export interface IoMessageMaker extends MessageInfo { /** * Create a message for this code, with or without payload. */ - msg: [T] extends [AbsentData] ? (message: string) => ActionLessMessage : (message: string, data: T) => ActionLessMessage; + msg: [T] extends [void] ? (message: string) => ActionLessMessage : (message: string, data: T) => ActionLessMessage; /** * Returns whether the given `IoMessage` instance matches the current message definition @@ -92,8 +92,12 @@ type CodeInfoMaybeInterface = [T] extends [AbsentData] ? Omit(details: CodeInfoMaybeInterface) => message('trace', details); export const debug = (details: CodeInfoMaybeInterface) => message('debug', details); @@ -113,8 +117,8 @@ export interface IoRequestMaker extends MessageInfo { /** * Create a message for this code, with or without payload. */ - req: [T] extends [AbsentData] - ? (message: string) => ActionLessMessage + req: [T] extends [void] + ? (message: string) => ActionLessMessage : [U] extends [boolean] ? (message: string, data: T) => ActionLessRequest : (message: string, data: T, defaultResponse: U) => ActionLessRequest; From f9e68aaec049d4cdc2c5563a8d8e4fdb8c529c3e Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Thu, 9 Jul 2026 01:12:16 -0400 Subject: [PATCH 03/13] chore: midwork --- .../@aws-cdk/toolkit-lib/lib/api/io/index.ts | 4 - .../toolkit-lib/lib/api/io/listeners.ts | 135 +++++----- .../lib/api/io/private/message-maker.ts | 14 +- .../lib/api/io/private/messages.ts | 234 +++++++++--------- .../toolkit-lib/lib/payloads/index.ts | 1 - .../toolkit-lib/test/api/io/listeners.test.ts | 45 ++-- 6 files changed, 222 insertions(+), 211 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts index 2871fcf2a..fb4a52305 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts @@ -2,7 +2,3 @@ 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'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts index 2397aecb8..d108b64ed 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts @@ -1,13 +1,25 @@ import type { IIoHost } from './io-host'; -import type { IoMessage, IoMessageLevel, IoRequest } from './io-message'; +import type { IoMessage, IoMessageCode, 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'; +import type { MessageListenerResultOrPromise } from './private/listener-registry'; -// 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'; +// 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) => boolean; /** * An `IIoHost` that additionally lets listeners be attached to it. @@ -15,11 +27,16 @@ export type { MessageListenerResult, MessageListenerResultOrPromise, MessageSele * 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 the - * selector. + * 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 @@ -27,26 +44,25 @@ export interface IoHostWithListeners extends IIoHost { * 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(IO.CDK_TOOLKIT_I2901, async (msg) => { - * myCount += msg.data.stacks.length; + * const dispose = host.on('CDK_TOOLKIT_I2901', async (msg) => { + * myCount += (msg.data as StackDetailsPayload).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 })); + * // A predicate matches a family of messages, e.g. every warning: + * const dispose = host.on((msg) => msg.level === 'warn', (msg) => { ... }); * ``` */ - on( - selector: IoMessageMaker | IoRequestMaker | ((msg: IoMessage) => msg is IoMessage), - listener: (msg: IoMessage) => MessageListenerResultOrPromise, - ): () => void; on( - predicate: (msg: IoMessage) => boolean, + selector: IoMessageCode | MessagePredicate, listener: (msg: IoMessage) => MessageListenerResultOrPromise, ): () => void; @@ -54,12 +70,8 @@ export interface IoHostWithListeners extends IIoHost { * Like `on`, but the listener is automatically removed after it has been * invoked once. */ - once( - selector: IoMessageMaker | IoRequestMaker | ((msg: IoMessage) => msg is IoMessage), - listener: (msg: IoMessage) => MessageListenerResultOrPromise, - ): () => void; once( - predicate: (msg: IoMessage) => boolean, + selector: IoMessageCode | MessagePredicate, listener: (msg: IoMessage) => MessageListenerResultOrPromise, ): () => void; @@ -74,13 +86,13 @@ export interface IoHostWithListeners extends IIoHost { * * @example * ```ts - * const dispose = host.rewrite(IO.CDK_TOOLKIT_I2901, (msg) => - * serializeStructure(msg.data.stacks, true)); + * const dispose = host.rewrite('CDK_TOOLKIT_I2901', (msg) => + * serializeStructure((msg.data as StackDetailsPayload).stacks, true)); * ``` */ - rewrite( - code: IoMessageMaker | IoRequestMaker, - formatter: (msg: IoMessage) => string, + rewrite( + code: IoMessageCode, + formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void; @@ -88,9 +100,9 @@ export interface IoHostWithListeners extends IIoHost { * Like `rewrite`, but the formatter is automatically removed after it has * been applied once. */ - rewriteOnce( - code: IoMessageMaker | IoRequestMaker, - formatter: (msg: IoMessage) => string, + rewriteOnce( + code: IoMessageCode, + formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void; @@ -107,15 +119,15 @@ export interface IoHostWithListeners extends IIoHost { * * @example * ```ts - * const dispose = host.respond(IO.CDK_TOOLKIT_I7010, true); + * const dispose = host.respond('CDK_TOOLKIT_I7010', true); * ``` */ - respond(code: IoRequestMaker, value: U, suppressQuestion?: boolean): () => void; + respond(code: IoMessageCode, value: unknown, suppressQuestion?: boolean): () => void; /** * Like `respond`, but the answer is given only once and then removed. */ - respondOnce(code: IoRequestMaker, value: U, suppressQuestion?: boolean): () => void; + respondOnce(code: IoMessageCode, value: unknown, suppressQuestion?: boolean): () => void; } /** @@ -136,7 +148,7 @@ export interface IoHostWithListeners extends IIoHost { * ```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; }); + * host.on('CDK_TOOLKIT_I2901', (m) => { count += (m.data as StackDetailsPayload).stacks.length; }); * const toolkit = new Toolkit({ ioHost: host }); * ``` */ @@ -149,7 +161,7 @@ export function withListeners(host: IIoHost): IoHostWithListeners { * * 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`. + * `IIoHost`, and adapts the public code-keyed API onto the registry's matchers. */ class ListeningIoHost implements IoHostWithListeners { private readonly registry = new ListenerRegistry(); @@ -186,35 +198,46 @@ class ListeningIoHost implements IoHostWithListeners { return this.inner.requestResponse(message); } - public on(selector: MessageSelector, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { - return this.registry.on(selector as any, listener as any); + public on(selector: IoMessageCode | MessagePredicate, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { + return this.registry.on(toMatcher(selector), listener); } - public once(selector: MessageSelector, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { - return this.registry.once(selector as any, listener as any); + public once(selector: IoMessageCode | MessagePredicate, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { + return this.registry.once(toMatcher(selector), listener); } - public rewrite( - code: IoMessageMaker | IoRequestMaker, - formatter: (msg: IoMessage) => string, - level?: IoMessageLevel, - ): () => void { - return this.registry.rewrite(code, formatter, level); + public rewrite(code: IoMessageCode, formatter: (msg: IoMessage) => string, level?: IoMessageLevel): () => void { + return this.registry.on(byCode(code), (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); } - public rewriteOnce( - code: IoMessageMaker | IoRequestMaker, - formatter: (msg: IoMessage) => string, - level?: IoMessageLevel, - ): () => void { - return this.registry.rewriteOnce(code, formatter, level); + public rewriteOnce(code: IoMessageCode, formatter: (msg: IoMessage) => string, level?: IoMessageLevel): () => void { + return this.registry.once(byCode(code), (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); } - public respond(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { - return this.registry.respond(code, value, suppressQuestion); + public respond(code: IoMessageCode, value: unknown, suppressQuestion = true): () => void { + // Only answer requests; on a notification code there's nothing to answer, so + // leave it untouched rather than suppress it (which would drop the message). + return this.registry.on(byCode(code), (msg) => + 'defaultResponse' in msg ? { respond: value, preventDefault: suppressQuestion } : undefined); } - public respondOnce(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { - return this.registry.respondOnce(code, value, suppressQuestion); + public respondOnce(code: IoMessageCode, value: unknown, suppressQuestion = true): () => void { + return this.registry.once(byCode(code), (msg) => + 'defaultResponse' in msg ? { respond: value, preventDefault: suppressQuestion } : undefined); } } + +/** + * 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: IoMessageCode | MessagePredicate): MessagePredicate { + return typeof selector === 'string' ? byCode(selector) : selector; +} + +/** + * Build a matcher that fires for messages carrying the given code. + */ +function byCode(code: IoMessageCode): MessagePredicate { + return (msg) => msg.code === code; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts index f51805434..4eae4b95a 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts @@ -4,7 +4,7 @@ import type { ActionLessMessage, ActionLessRequest } from './io-helper'; /** * Information for each IO Message Code. */ -export interface CodeInfo { +interface CodeInfo { /** * The message code. */ @@ -30,7 +30,7 @@ export interface CodeInfo { /** * Information for each IO Message */ -export interface MessageInfo extends CodeInfo { +interface MessageInfo extends CodeInfo { /** * The message level */ @@ -44,7 +44,7 @@ export interface IoMessageMaker extends MessageInfo { /** * Create a message for this code, with or without payload. */ - msg: [T] extends [void] ? (message: string) => ActionLessMessage : (message: string, data: T) => ActionLessMessage; + msg: [T] extends [AbsentData] ? (message: string) => ActionLessMessage : (message: string, data: T) => ActionLessMessage; /** * Returns whether the given `IoMessage` instance matches the current message definition @@ -92,10 +92,6 @@ type CodeInfoMaybeInterface = [T] extends [AbsentData] ? Omit extends MessageInfo { /** * Create a message for this code, with or without payload. */ - req: [T] extends [void] - ? (message: string) => ActionLessMessage + req: [T] extends [AbsentData] + ? (message: string) => ActionLessMessage : [U] extends [boolean] ? (message: string, data: T) => ActionLessRequest : (message: string, data: T, defaultResponse: U) => ActionLessRequest; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index 845bcd687..02b953c7a 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -1,5 +1,5 @@ import type * as cxapi from '@aws-cdk/cloud-assembly-api'; -import { confirm, debug, error, info, question, result, trace, warn } from './message-maker'; +import * as make from './message-maker'; import type { SpanDefinition } from './span'; import type { DiagnosedStack } from '../../../actions/diagnose'; import type { ValidateResult } from '../../../actions/validate'; @@ -41,618 +41,618 @@ import type { FileWatchEvent, WatchSettings } from '../../../payloads/watch'; */ export const IO = { // warnings & errors - CDK_TOOLKIT_W0100: warn({ + CDK_TOOLKIT_W0100: make.warn({ code: 'CDK_TOOLKIT_W0100', description: 'Credential plugin warnings', }), // 1: Synth (1xxx) - CDK_TOOLKIT_I1000: info({ + CDK_TOOLKIT_I1000: make.info({ code: 'CDK_TOOLKIT_I1000', description: 'Provides synthesis times.', interface: 'Operation', }), - CDK_TOOLKIT_I1001: trace({ + CDK_TOOLKIT_I1001: make.trace({ code: 'CDK_TOOLKIT_I1001', description: 'Cloud Assembly synthesis is starting', interface: 'StackSelectionDetails', }), - CDK_TOOLKIT_I1002: info({ + CDK_TOOLKIT_I1002: make.info({ code: 'CDK_TOOLKIT_I1002', description: 'Stacks added to the selection because they are dependencies of the selected stacks (upstream expansion)', }), - CDK_TOOLKIT_I1003: info({ + CDK_TOOLKIT_I1003: make.info({ code: 'CDK_TOOLKIT_I1003', description: 'Stacks added to the selection because they are dependent on the selected stacks (downstream expansion)', }), - CDK_TOOLKIT_I1901: result({ + CDK_TOOLKIT_I1901: make.result({ code: 'CDK_TOOLKIT_I1901', description: 'Provides stack data', interface: 'StackAndAssemblyData', }), - CDK_TOOLKIT_I1902: result({ + CDK_TOOLKIT_I1902: make.result({ code: 'CDK_TOOLKIT_I1902', description: 'Successfully deployed stacks', interface: 'AssemblyData', }), // 2: List (2xxx) - CDK_TOOLKIT_I2901: result({ + CDK_TOOLKIT_I2901: make.result({ code: 'CDK_TOOLKIT_I2901', description: 'Provides details on the selected stacks and their dependencies', interface: 'StackDetailsPayload', }), // 3: Import & Migrate - CDK_TOOLKIT_I3100: confirm({ + CDK_TOOLKIT_I3100: make.confirm({ code: 'CDK_TOOLKIT_I3100', description: 'Confirm the import of a specific resource', interface: 'ResourceImportRequest', }), - CDK_TOOLKIT_I3110: question({ + CDK_TOOLKIT_I3110: make.question({ code: 'CDK_TOOLKIT_I3110', description: 'Additional information is needed to identify a resource', interface: 'ResourceIdentificationRequest', }), - CDK_TOOLKIT_E3900: error({ + CDK_TOOLKIT_E3900: make.error({ code: 'CDK_TOOLKIT_E3900', description: 'Resource import failed', interface: 'ErrorPayload', }), // 4: Diff (40xx - 44xx) - CDK_TOOLKIT_I4000: trace({ + CDK_TOOLKIT_I4000: make.trace({ code: 'CDK_TOOLKIT_I4000', description: 'Diff stacks is starting', interface: 'StackSelectionDetails', }), - CDK_TOOLKIT_I4001: result({ + CDK_TOOLKIT_I4001: make.result({ code: 'CDK_TOOLKIT_I4001', description: 'Output of the diff command', interface: 'DiffResult', }), - CDK_TOOLKIT_I4002: result({ + CDK_TOOLKIT_I4002: make.result({ code: 'CDK_TOOLKIT_I4002', description: 'The diff for a single stack', interface: 'StackDiff', }), // 4: Drift (45xx - 49xx) - CDK_TOOLKIT_I4500: trace({ + CDK_TOOLKIT_I4500: make.trace({ code: 'CDK_TOOLKIT_I4500', description: 'Drift detection is starting', interface: 'StackSelectionDetails', }), - CDK_TOOLKIT_I4509: result({ + CDK_TOOLKIT_I4509: make.result({ code: 'CDK_TOOLKIT_I4592', description: 'Results of the drift', interface: 'Duration', }), - CDK_TOOLKIT_I4590: result({ + CDK_TOOLKIT_I4590: make.result({ code: 'CDK_TOOLKIT_I4590', description: 'Results of a stack drift', interface: 'DriftResultPayload', }), - CDK_TOOLKIT_W4591: warn({ + CDK_TOOLKIT_W4591: make.warn({ code: 'CDK_TOOLKIT_W4591', description: 'Missing drift result fort a stack.', interface: 'SingleStack', }), // 5: Deploy & Watch (5xxx) - CDK_TOOLKIT_I5000: info({ + CDK_TOOLKIT_I5000: make.info({ code: 'CDK_TOOLKIT_I5000', description: 'Provides deployment times', interface: 'Duration', }), - CDK_TOOLKIT_I5001: info({ + CDK_TOOLKIT_I5001: make.info({ code: 'CDK_TOOLKIT_I5001', description: 'Provides total time in deploy action, including synth and rollback', interface: 'Duration', }), - CDK_TOOLKIT_I5002: info({ + CDK_TOOLKIT_I5002: make.info({ code: 'CDK_TOOLKIT_I5002', description: 'Provides time for resource migration', interface: 'Duration', }), - CDK_TOOLKIT_W5021: warn({ + CDK_TOOLKIT_W5021: make.warn({ code: 'CDK_TOOLKIT_W5021', description: 'Empty non-existent stack, deployment is skipped', }), - CDK_TOOLKIT_W5022: warn({ + CDK_TOOLKIT_W5022: make.warn({ code: 'CDK_TOOLKIT_W5022', description: 'Empty existing stack, stack will be destroyed', }), - CDK_TOOLKIT_I5031: info({ + CDK_TOOLKIT_I5031: make.info({ code: 'CDK_TOOLKIT_I5031', description: 'Informs about any log groups that are traced as part of the deployment', }), - CDK_TOOLKIT_I5032: debug({ + CDK_TOOLKIT_I5032: make.debug({ code: 'CDK_TOOLKIT_I5032', description: 'Start monitoring log groups', interface: 'CloudWatchLogMonitorControlEvent', }), - CDK_TOOLKIT_I5033: info({ + CDK_TOOLKIT_I5033: make.info({ code: 'CDK_TOOLKIT_I5033', description: 'A log event received from Cloud Watch', interface: 'CloudWatchLogEvent', }), - CDK_TOOLKIT_I5034: debug({ + CDK_TOOLKIT_I5034: make.debug({ code: 'CDK_TOOLKIT_I5034', description: 'Stop monitoring log groups', interface: 'CloudWatchLogMonitorControlEvent', }), - CDK_TOOLKIT_E5035: error({ + CDK_TOOLKIT_E5035: make.error({ code: 'CDK_TOOLKIT_E5035', description: 'A log monitoring error', interface: 'ErrorPayload', }), - CDK_TOOLKIT_I5050: confirm({ + CDK_TOOLKIT_I5050: make.confirm({ code: 'CDK_TOOLKIT_I5050', description: 'Confirm rollback during deployment', interface: 'ConfirmationRequest', }), - CDK_TOOLKIT_I5060: confirm({ + CDK_TOOLKIT_I5060: make.confirm({ code: 'CDK_TOOLKIT_I5060', description: 'Confirm deploy security sensitive changes', interface: 'DeployConfirmationRequest', }), - CDK_TOOLKIT_I5100: info({ + CDK_TOOLKIT_I5100: make.info({ code: 'CDK_TOOLKIT_I5100', description: 'Stack deploy progress', interface: 'StackDeployProgress', }), // Assets (52xx) - CDK_TOOLKIT_I5210: trace({ + CDK_TOOLKIT_I5210: make.trace({ code: 'CDK_TOOLKIT_I5210', description: 'Started building a specific asset', interface: 'BuildAsset', }), - CDK_TOOLKIT_I5211: trace({ + CDK_TOOLKIT_I5211: make.trace({ code: 'CDK_TOOLKIT_I5211', description: 'Building the asset has completed', interface: 'Duration', }), - CDK_TOOLKIT_I5220: trace({ + CDK_TOOLKIT_I5220: make.trace({ code: 'CDK_TOOLKIT_I5220', description: 'Started publishing a specific asset', interface: 'PublishAsset', }), - CDK_TOOLKIT_I5221: trace({ + CDK_TOOLKIT_I5221: make.trace({ code: 'CDK_TOOLKIT_I5221', description: 'Publishing the asset has completed', interface: 'Duration', }), - CDK_ASSETS_I5270: info({ + CDK_ASSETS_I5270: make.info({ code: 'CDK_ASSETS_I5270', description: 'Publishing the asset has started', interface: 'PublishAssetEvent', }), - CDK_ASSETS_I5271: debug({ + CDK_ASSETS_I5271: make.debug({ code: 'CDK_ASSETS_I5271', description: 'Debug messaged emitted during publishing of the asset', interface: 'PublishAssetEvent', }), - CDK_ASSETS_I5275: info({ + CDK_ASSETS_I5275: make.info({ code: 'CDK_ASSETS_I5275', description: 'Publishing the asset has completed successfully', interface: 'PublishAssetEvent', }), - CDK_ASSETS_E5279: error({ + CDK_ASSETS_E5279: make.error({ code: 'CDK_ASSETS_E5279', description: 'There was an error while publishing the asset', interface: 'PublishAssetEvent', }), // Watch (53xx) - CDK_TOOLKIT_I5310: debug({ + CDK_TOOLKIT_I5310: make.debug({ code: 'CDK_TOOLKIT_I5310', description: 'The computed settings used for file watching', interface: 'WatchSettings', }), - CDK_TOOLKIT_I5311: info({ + CDK_TOOLKIT_I5311: make.info({ code: 'CDK_TOOLKIT_I5311', description: 'File watching started', interface: 'FileWatchEvent', }), - CDK_TOOLKIT_I5312: info({ + CDK_TOOLKIT_I5312: make.info({ code: 'CDK_TOOLKIT_I5312', description: 'File event detected, starting deployment', interface: 'FileWatchEvent', }), - CDK_TOOLKIT_I5313: info({ + CDK_TOOLKIT_I5313: make.info({ code: 'CDK_TOOLKIT_I5313', description: 'File event detected during active deployment, changes are queued', interface: 'FileWatchEvent', }), - CDK_TOOLKIT_I5314: info({ + CDK_TOOLKIT_I5314: make.info({ code: 'CDK_TOOLKIT_I5314', description: 'Initial watch deployment started', }), - CDK_TOOLKIT_I5315: info({ + CDK_TOOLKIT_I5315: make.info({ code: 'CDK_TOOLKIT_I5315', description: 'Queued watch deployment started', }), // Hotswap (54xx) - CDK_TOOLKIT_I5400: trace({ + CDK_TOOLKIT_I5400: make.trace({ code: 'CDK_TOOLKIT_I5400', description: 'Attempting a hotswap deployment', interface: 'HotswapDeploymentAttempt', }), - CDK_TOOLKIT_I5401: trace({ + CDK_TOOLKIT_I5401: make.trace({ code: 'CDK_TOOLKIT_I5401', description: 'Computed details for the hotswap deployment', interface: 'HotswapDeploymentDetails', }), - CDK_TOOLKIT_I5402: info({ + CDK_TOOLKIT_I5402: make.info({ code: 'CDK_TOOLKIT_I5402', description: 'A hotswappable change is processed as part of a hotswap deployment', interface: 'HotswappableChange', }), - CDK_TOOLKIT_I5403: info({ + CDK_TOOLKIT_I5403: make.info({ code: 'CDK_TOOLKIT_I5403', description: 'The hotswappable change has completed processing', interface: 'HotswappableChange', }), - CDK_TOOLKIT_I5410: info({ + CDK_TOOLKIT_I5410: make.info({ code: 'CDK_TOOLKIT_I5410', description: 'Hotswap deployment has ended, a full deployment might still follow if needed', interface: 'HotswapResult', }), // Stack Monitor (55xx) - CDK_TOOLKIT_I5501: info({ + CDK_TOOLKIT_I5501: make.info({ code: 'CDK_TOOLKIT_I5501', description: 'Stack Monitoring: Start monitoring of a single stack', interface: 'StackMonitoringControlEvent', }), - CDK_TOOLKIT_I5502: info({ + CDK_TOOLKIT_I5502: make.info({ code: 'CDK_TOOLKIT_I5502', description: 'Stack Monitoring: Activity event for a single stack', interface: 'StackActivity', }), - CDK_TOOLKIT_I5503: info({ + CDK_TOOLKIT_I5503: make.info({ code: 'CDK_TOOLKIT_I5503', description: 'Stack Monitoring: Finished monitoring of a single stack', interface: 'StackMonitoringControlEvent', }), // Success (59xx) - CDK_TOOLKIT_I5900: result({ + CDK_TOOLKIT_I5900: make.result({ code: 'CDK_TOOLKIT_I5900', description: 'Deployment results on success', interface: 'SuccessfulDeployStackResult', }), - CDK_TOOLKIT_I5901: info({ + CDK_TOOLKIT_I5901: make.info({ code: 'CDK_TOOLKIT_I5901', description: 'Generic deployment success messages', }), - CDK_TOOLKIT_W5902: warn({ + CDK_TOOLKIT_W5902: make.warn({ code: 'CDK_TOOLKIT_W5902', description: 'Express Mode deployment completed with resources still stabilizing', }), - CDK_TOOLKIT_W5400: warn({ + CDK_TOOLKIT_W5400: make.warn({ code: 'CDK_TOOLKIT_W5400', description: 'Hotswap disclosure message', }), - CDK_TOOLKIT_E5001: error({ + CDK_TOOLKIT_E5001: make.error({ code: 'CDK_TOOLKIT_E5001', description: 'No stacks found', }), - CDK_TOOLKIT_E5500: error({ + CDK_TOOLKIT_E5500: make.error({ code: 'CDK_TOOLKIT_E5500', description: 'Stack Monitoring error', interface: 'ErrorPayload', }), // 6: Rollback (6xxx) - CDK_TOOLKIT_I6000: info({ + CDK_TOOLKIT_I6000: make.info({ code: 'CDK_TOOLKIT_I6000', description: 'Provides rollback times', interface: 'Duration', }), - CDK_TOOLKIT_I6100: info({ + CDK_TOOLKIT_I6100: make.info({ code: 'CDK_TOOLKIT_I6100', description: 'Stack rollback progress', interface: 'StackRollbackProgress', }), - CDK_TOOLKIT_E6001: error({ + CDK_TOOLKIT_E6001: make.error({ code: 'CDK_TOOLKIT_E6001', description: 'No stacks found', }), - CDK_TOOLKIT_E6900: error({ + CDK_TOOLKIT_E6900: make.error({ code: 'CDK_TOOLKIT_E6900', description: 'Rollback failed', interface: 'ErrorPayload', }), // 7: Destroy (7xxx) - CDK_TOOLKIT_I7000: info({ + CDK_TOOLKIT_I7000: make.info({ code: 'CDK_TOOLKIT_I7000', description: 'Provides destroy times', interface: 'Duration', }), - CDK_TOOLKIT_I7001: trace({ + CDK_TOOLKIT_I7001: make.trace({ code: 'CDK_TOOLKIT_I7001', description: 'Provides destroy time for a single stack', interface: 'Duration', }), - CDK_TOOLKIT_I7010: confirm({ + CDK_TOOLKIT_I7010: make.confirm({ code: 'CDK_TOOLKIT_I7010', description: 'Confirm destroy stacks', interface: 'ConfirmationRequest', }), - CDK_TOOLKIT_I7100: info({ + CDK_TOOLKIT_I7100: make.info({ code: 'CDK_TOOLKIT_I7100', description: 'Stack destroy progress', interface: 'StackDestroyProgress', }), - CDK_TOOLKIT_I7101: trace({ + CDK_TOOLKIT_I7101: make.trace({ code: 'CDK_TOOLKIT_I7101', description: 'Start stack destroying', interface: 'StackDestroy', }), - CDK_TOOLKIT_I7900: result({ + CDK_TOOLKIT_I7900: make.result({ code: 'CDK_TOOLKIT_I7900', description: 'Stack deletion succeeded', interface: 'cxapi.CloudFormationStackArtifact', }), - CDK_TOOLKIT_W7902: warn({ + CDK_TOOLKIT_W7902: make.warn({ code: 'CDK_TOOLKIT_W7902', description: 'Express Mode deletion completed with resources still tearing down', }), - CDK_TOOLKIT_E7010: error({ + CDK_TOOLKIT_E7010: make.error({ code: 'CDK_TOOLKIT_E7010', description: 'Action was aborted due to negative confirmation of request', }), - CDK_TOOLKIT_E7900: error({ + CDK_TOOLKIT_E7900: make.error({ code: 'CDK_TOOLKIT_E7900', description: 'Stack deletion failed', interface: 'ErrorPayload', }), // 8. Refactor (8xxx) - CDK_TOOLKIT_E8900: error({ + CDK_TOOLKIT_E8900: make.error({ code: 'CDK_TOOLKIT_E8900', description: 'Stack refactor failed', interface: 'ErrorPayload', }), - CDK_TOOLKIT_I8900: result({ + CDK_TOOLKIT_I8900: make.result({ code: 'CDK_TOOLKIT_I8900', description: 'Refactor result', interface: 'RefactorResult', }), - CDK_TOOLKIT_I8910: confirm({ + CDK_TOOLKIT_I8910: make.confirm({ code: 'CDK_TOOLKIT_I8910', description: 'Confirm refactor', interface: 'ConfirmationRequest', }), - CDK_TOOLKIT_W8010: warn({ + CDK_TOOLKIT_W8010: make.warn({ code: 'CDK_TOOLKIT_W8010', description: 'Refactor execution not yet supported', }), // Orphan (88xx) - CDK_TOOLKIT_I8810: confirm({ + CDK_TOOLKIT_I8810: make.confirm({ code: 'CDK_TOOLKIT_I8810', description: 'Confirm orphan resources', interface: 'ConfirmationRequest', }), // 9: Bootstrap, gc, flags & publish (9xxx) - CDK_TOOLKIT_I9000: info({ + CDK_TOOLKIT_I9000: make.info({ code: 'CDK_TOOLKIT_I9000', description: 'Provides bootstrap times', interface: 'Duration', }), - CDK_TOOLKIT_I9100: info({ + CDK_TOOLKIT_I9100: make.info({ code: 'CDK_TOOLKIT_I9100', description: 'Bootstrap progress', interface: 'BootstrapEnvironmentProgress', }), // gc (92xx) - CDK_TOOLKIT_I9210: question({ + CDK_TOOLKIT_I9210: make.question({ code: 'CDK_TOOLKIT_I9210', description: 'Confirm the deletion of a batch of assets', interface: 'AssetBatchDeletionRequest', }), - CDK_TOOLKIT_I9900: result<{ environment: cxapi.Environment }>({ + CDK_TOOLKIT_I9900: make.result<{ environment: cxapi.Environment }>({ code: 'CDK_TOOLKIT_I9900', description: 'Bootstrap results on success', interface: 'cxapi.Environment', }), - CDK_TOOLKIT_W9902: warn({ + CDK_TOOLKIT_W9902: make.warn({ code: 'CDK_TOOLKIT_W9902', description: 'Bootstrap completed with Express Mode, resources still stabilizing', }), - CDK_TOOLKIT_E9900: error({ + CDK_TOOLKIT_E9900: make.error({ code: 'CDK_TOOLKIT_E9900', description: 'Bootstrap failed', interface: 'ErrorPayload', }), // flags (93xx) - CDK_TOOLKIT_I9300: info({ + CDK_TOOLKIT_I9300: make.info({ code: 'CDK_TOOLKIT_I9300', description: 'Confirm the feature flag configuration changes', interface: 'FeatureFlagChangeRequest', }), // publish (94xx) - CDK_TOOLKIT_I9400: info({ + CDK_TOOLKIT_I9400: make.info({ code: 'CDK_TOOLKIT_I9400', description: 'All assets are already published', }), - CDK_TOOLKIT_I9401: info({ + CDK_TOOLKIT_I9401: make.info({ code: 'CDK_TOOLKIT_I9401', description: 'Publishing assets', interface: 'AssetsPayload', }), - CDK_TOOLKIT_I9402: result({ + CDK_TOOLKIT_I9402: make.result({ code: 'CDK_TOOLKIT_I9402', description: 'Publish assets results on success', interface: 'AssetsPayload', }), // diagnose (95xx) - CDK_TOOLKIT_I9500: info({ + CDK_TOOLKIT_I9500: make.info({ code: 'CDK_TOOLKIT_I9500', description: 'Stack diagnosis (no problems found)', interface: 'DiagnosedStack', }), - CDK_TOOLKIT_E9500: error({ + CDK_TOOLKIT_E9500: make.error({ code: 'CDK_TOOLKIT_E9500', description: 'Stack diagnosis (problems found)', interface: 'DiagnosedStack', }), - CDK_TOOLKIT_W9501: warn({ + CDK_TOOLKIT_W9501: make.warn({ code: 'CDK_TOOLKIT_W9501', description: 'Stack diagnosis (diagnosis could not be performed)', interface: 'DiagnosedStack', }), // validate (96xx) - CDK_TOOLKIT_I9600: info({ + CDK_TOOLKIT_I9600: make.info({ code: 'CDK_TOOLKIT_I9600', description: 'Validation did not find any problems', interface: 'ValidateResult', }), - CDK_TOOLKIT_E9600: error({ + CDK_TOOLKIT_E9600: make.error({ code: 'CDK_TOOLKIT_E9600', description: 'Policy validation failed', interface: 'ValidateResult', }), - CDK_TOOLKIT_I9601: info({ + CDK_TOOLKIT_I9601: make.info({ code: 'CDK_TOOLKIT_I9601', description: 'No policy validation report found', }), - CDK_TOOLKIT_W9602: warn({ + CDK_TOOLKIT_W9602: make.warn({ code: 'CDK_TOOLKIT_W9602', description: 'Online validation could not be completed for a stack', }), // Notices - CDK_TOOLKIT_I0100: info({ + CDK_TOOLKIT_I0100: make.info({ code: 'CDK_TOOLKIT_I0100', description: 'Notices decoration (the header or footer of a list of notices)', }), - CDK_TOOLKIT_W0101: warn({ + CDK_TOOLKIT_W0101: make.warn({ code: 'CDK_TOOLKIT_W0101', description: 'A notice that is marked as a warning', }), - CDK_TOOLKIT_E0101: error({ + CDK_TOOLKIT_E0101: make.error({ code: 'CDK_TOOLKIT_E0101', description: 'A notice that is marked as an error', }), - CDK_TOOLKIT_I0101: info({ + CDK_TOOLKIT_I0101: make.info({ code: 'CDK_TOOLKIT_I0101', description: 'A notice that is marked as informational', }), // Assembly codes - CDK_ASSEMBLY_I0010: debug({ + CDK_ASSEMBLY_I0010: make.debug({ code: 'CDK_ASSEMBLY_I0010', description: 'Generic environment preparation debug messages', }), - CDK_ASSEMBLY_W0010: warn({ + CDK_ASSEMBLY_W0010: make.warn({ code: 'CDK_ASSEMBLY_W0010', description: 'Emitted if the found framework version does not support context overflow', }), - CDK_ASSEMBLY_I0042: debug({ + CDK_ASSEMBLY_I0042: make.debug({ code: 'CDK_ASSEMBLY_I0042', description: 'Writing context updates', interface: 'UpdatedContext', }), - CDK_ASSEMBLY_I0240: debug({ + CDK_ASSEMBLY_I0240: make.debug({ code: 'CDK_ASSEMBLY_I0240', description: 'Context lookup was stopped as no further progress was made. ', interface: 'MissingContext', }), - CDK_ASSEMBLY_I0241: debug({ + CDK_ASSEMBLY_I0241: make.debug({ code: 'CDK_ASSEMBLY_I0241', description: 'Fetching missing context. This is an iterative message that may appear multiple times with different missing keys.', interface: 'MissingContext', }), - CDK_ASSEMBLY_I1000: debug({ + CDK_ASSEMBLY_I1000: make.debug({ code: 'CDK_ASSEMBLY_I1000', description: 'Cloud assembly output starts', }), - CDK_ASSEMBLY_I1001: info({ + CDK_ASSEMBLY_I1001: make.info({ code: 'CDK_ASSEMBLY_I1001', description: 'Output lines emitted by the cloud assembly to stdout', }), - CDK_ASSEMBLY_E1002: error({ + CDK_ASSEMBLY_E1002: make.error({ code: 'CDK_ASSEMBLY_E1002', description: 'Output lines emitted by the cloud assembly to stderr', }), - CDK_ASSEMBLY_I1003: info({ + CDK_ASSEMBLY_I1003: make.info({ code: 'CDK_ASSEMBLY_I1003', description: 'Cloud assembly output finished', }), - CDK_ASSEMBLY_E1111: error({ + CDK_ASSEMBLY_E1111: make.error({ code: 'CDK_ASSEMBLY_E1111', description: 'Incompatible CDK CLI version. Upgrade needed.', interface: 'ErrorPayload', }), - CDK_ASSEMBLY_I0150: debug({ + CDK_ASSEMBLY_I0150: make.debug({ code: 'CDK_ASSEMBLY_I0150', description: 'Indicates the use of a pre-synthesized cloud assembly directory', }), - CDK_ASSEMBLY_I0300: info({ + CDK_ASSEMBLY_I0300: make.info({ code: 'CDK_ASSEMBLY_I0300', description: 'An info message emitted by a Context Provider', interface: 'ContextProviderMessageSource', }), - CDK_ASSEMBLY_I0301: debug({ + CDK_ASSEMBLY_I0301: make.debug({ code: 'CDK_ASSEMBLY_I0301', description: 'A debug message emitted by a Context Provider', interface: 'ContextProviderMessageSource', }), // Assembly Annotations - CDK_ASSEMBLY_I9999: info({ + CDK_ASSEMBLY_I9999: make.info({ code: 'CDK_ASSEMBLY_I9999', description: 'Annotations emitted by the cloud assembly', interface: 'cxapi.SynthesisMessage', }), - CDK_ASSEMBLY_W9999: warn({ + CDK_ASSEMBLY_W9999: make.warn({ code: 'CDK_ASSEMBLY_W9999', description: 'Warnings emitted by the cloud assembly', interface: 'cxapi.SynthesisMessage', }), - CDK_ASSEMBLY_E9999: error({ + CDK_ASSEMBLY_E9999: make.error({ code: 'CDK_ASSEMBLY_E9999', description: 'Errors emitted by the cloud assembly', interface: 'cxapi.SynthesisMessage', }), // SDK codes - CDK_SDK_I0100: trace({ + CDK_SDK_I0100: make.trace({ code: 'CDK_SDK_I0100', description: 'An SDK trace. SDK traces are emitted as traces to the IoHost, but contain the original SDK logging level.', interface: 'SdkTrace', }), - CDK_SDK_I1100: question({ + CDK_SDK_I1100: make.question({ code: 'CDK_SDK_I1100', description: 'Get an MFA token for an MFA device.', interface: 'MfaTokenRequest', diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts index fdcc11918..1e3da5efe 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts @@ -19,4 +19,3 @@ export * from './logs-monitor'; export * from './hotswap'; export * from './gc'; export * from './import'; -export * from './flags'; diff --git a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts index ab4637ce1..448660092 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts @@ -1,5 +1,5 @@ import type { IIoHost, IoMessage, IoRequest } from '../../../lib/api/io'; -import { matchAny, withListeners, IO } from '../../../lib/api/io'; +import { withListeners } from '../../../lib/api/io'; /** * A minimal `IIoHost` that records what it is asked to handle, so we can assert @@ -22,8 +22,8 @@ class RecordingIoHost implements IIoHost { } } -const I2901 = IO.CDK_TOOLKIT_I2901; // list result, payload has `stacks` -const I7010 = IO.CDK_TOOLKIT_I7010; // destroy confirmation request (boolean) +const I2901 = 'CDK_TOOLKIT_I2901'; // list result, payload has `stacks` +const I7010 = 'CDK_TOOLKIT_I7010'; // destroy confirmation request (boolean) function notification(over: Partial> = {}): IoMessage { return { @@ -69,7 +69,7 @@ describe('withListeners', () => { describe('on', () => { test('runs the listener for a matching code and forwards the message', async () => { const host = withListeners(inner); - const seen: Array<{ stacks: unknown[] }> = []; + const seen: Array = []; host.on(I2901, (m) => { seen.push(m.data); }); @@ -135,7 +135,7 @@ describe('withListeners', () => { expect(fn).toHaveBeenCalledTimes(1); }); - test('matches on a predicate selector', async () => { + test('matches on a predicate selector, firing only for matching messages', async () => { const host = withListeners(inner); const fn = jest.fn(); host.on((m) => m.level === 'warn', fn); @@ -145,27 +145,27 @@ describe('withListeners', () => { expect(fn).toHaveBeenCalledTimes(1); }); + }); - test("matches on a maker's .is type guard", async () => { + describe('once', () => { + test('runs only for the first matching message', async () => { const host = withListeners(inner); const fn = jest.fn(); - host.on(I2901.is, fn); + host.once(I2901, fn); await host.notify(notification()); - await host.notify(notification({ code: 'CDK_TOOLKIT_I0001' })); + await host.notify(notification()); expect(fn).toHaveBeenCalledTimes(1); }); - }); - describe('once', () => { - test('runs only for the first matching message', async () => { + test('accepts a predicate selector', async () => { const host = withListeners(inner); const fn = jest.fn(); - host.once(I2901, fn); + host.once((m) => m.level === 'warn', fn); - await host.notify(notification()); - await host.notify(notification()); + await host.notify(notification({ level: 'warn' })); + await host.notify(notification({ level: 'warn' })); expect(fn).toHaveBeenCalledTimes(1); }); @@ -174,7 +174,7 @@ describe('withListeners', () => { describe('rewrite', () => { test('replaces the forwarded message text, leaving the code intact', async () => { const host = withListeners(inner); - host.rewrite(I2901, (m) => `rewritten: ${m.data.stacks.length}`); + host.rewrite(I2901, (m) => `rewritten: ${(m.data as { stacks: unknown[] }).stacks.length}`); await host.notify(notification()); @@ -322,19 +322,16 @@ describe('withListeners', () => { expect(answer).toBe('the-default'); expect(inner.requested).toHaveLength(0); }); - }); - describe('matchAny', () => { - test('one listener fires for any of several codes', async () => { + test('respond on a notification code leaves the message alone instead of suppressing it', async () => { const host = withListeners(inner); - const fn = jest.fn(); - host.on(matchAny(IO.CDK_TOOLKIT_I5501, IO.CDK_TOOLKIT_I5502, IO.CDK_TOOLKIT_I5503), fn); + // I2901 is a notification, not a request: there is nothing to answer, so + // respond must not drop the message. + host.respond(I2901, true); - await host.notify(notification({ code: 'CDK_TOOLKIT_I5502' })); - await host.notify(notification({ code: 'CDK_TOOLKIT_I5503' })); - await host.notify(notification({ code: 'CDK_TOOLKIT_I2901' })); + await host.notify(notification()); - expect(fn).toHaveBeenCalledTimes(2); + expect(inner.notified).toHaveLength(1); }); }); }); From b9195c55d777bdb5527ada9998f00f3065977081 Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Fri, 10 Jul 2026 15:57:15 -0400 Subject: [PATCH 04/13] chore: midwork --- .../toolkit-lib/lib/api/io/io-message.ts | 18 ++++ .../toolkit-lib/lib/api/io/listeners.ts | 98 ++++++++++++++----- .../lib/api/io/private/listener-registry.ts | 41 ++++---- .../lib/api/io/private/message-maker.ts | 12 +-- .../toolkit-lib/test/api/io/listeners.test.ts | 35 ++++++- 5 files changed, 149 insertions(+), 55 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/io-message.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/io-message.ts index 692f0461b..22700eff7 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/io-message.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/io-message.ts @@ -82,3 +82,21 @@ export interface IoRequest extends IoMessage { readonly code: IoMessageCode; } + +/** + * A message matcher + * + * Decides whether a message matches a listener, narrowing its payload type `T`. + */ +export interface IMessageMatcher { + is(msg: IoMessage): msg is IoMessage; +} + +/** + * A request matcher. + * + * Carries the response type `U` so an answer can be typed. + */ +export interface IRequestMatcher extends IMessageMatcher { + is(msg: IoMessage): msg is IoRequest; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts index d108b64ed..b73276034 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts @@ -1,5 +1,5 @@ import type { IIoHost } from './io-host'; -import type { IoMessage, IoMessageCode, IoMessageLevel, IoRequest } from './io-message'; +import type { IoMessage, IoRequest, IMessageMatcher, IRequestMatcher, IoMessageCode, IoMessageLevel } from './io-message'; import { ListenerRegistry } from './private/listener-registry'; import type { MessageListenerResultOrPromise } from './private/listener-registry'; @@ -21,6 +21,17 @@ export type { MessageListenerResult, MessageListenerResultOrPromise } from './pr */ export type MessagePredicate = (msg: IoMessage) => 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. * @@ -61,6 +72,10 @@ export interface IoHostWithListeners extends IIoHost { * const dispose = host.on((msg) => msg.level === 'warn', (msg) => { ... }); * ``` */ + on( + matcher: IMessageMatcher, + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void; on( selector: IoMessageCode | MessagePredicate, listener: (msg: IoMessage) => MessageListenerResultOrPromise, @@ -70,15 +85,20 @@ export interface IoHostWithListeners extends IIoHost { * Like `on`, but the listener is automatically removed after it has been * invoked once. */ + once( + matcher: IMessageMatcher, + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void; once( selector: IoMessageCode | MessagePredicate, listener: (msg: IoMessage) => 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. + * 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`. @@ -90,8 +110,13 @@ export interface IoHostWithListeners extends IIoHost { * serializeStructure((msg.data as StackDetailsPayload).stacks, true)); * ``` */ + rewrite( + matcher: IMessageMatcher, + formatter: (msg: IoMessage) => string, + level?: IoMessageLevel, + ): () => void; rewrite( - code: IoMessageCode, + selector: IoMessageCode | MessagePredicate, formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void; @@ -100,8 +125,13 @@ export interface IoHostWithListeners extends IIoHost { * Like `rewrite`, but the formatter is automatically removed after it has * been applied once. */ + rewriteOnce( + matcher: IMessageMatcher, + formatter: (msg: IoMessage) => string, + level?: IoMessageLevel, + ): () => void; rewriteOnce( - code: IoMessageCode, + selector: IoMessageCode | MessagePredicate, formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void; @@ -122,12 +152,14 @@ export interface IoHostWithListeners extends IIoHost { * const dispose = host.respond('CDK_TOOLKIT_I7010', true); * ``` */ - respond(code: IoMessageCode, value: unknown, suppressQuestion?: boolean): () => void; + respond(matcher: IRequestMatcher, 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(code: IoMessageCode, value: unknown, suppressQuestion?: boolean): () => void; + respondOnce(matcher: IRequestMatcher, value: U, options?: RespondOptions): () => void; + respondOnce(code: IoMessageCode, value: unknown, options?: RespondOptions): () => void; } /** @@ -152,8 +184,8 @@ export interface IoHostWithListeners extends IIoHost { * const toolkit = new Toolkit({ ioHost: host }); * ``` */ -export function withListeners(host: IIoHost): IoHostWithListeners { - return new ListeningIoHost(host); +export function withListeners(host: T): T & IoHostWithListeners { + return new ListeningIoHost(host) as unknown as T & IoHostWithListeners; } /** @@ -198,31 +230,47 @@ class ListeningIoHost implements IoHostWithListeners { return this.inner.requestResponse(message); } - public on(selector: IoMessageCode | MessagePredicate, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { + public on( + selector: IMessageMatcher | IoMessageCode | MessagePredicate, + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void { return this.registry.on(toMatcher(selector), listener); } - public once(selector: IoMessageCode | MessagePredicate, listener: (msg: IoMessage) => MessageListenerResultOrPromise): () => void { + public once( + selector: IMessageMatcher | IoMessageCode | MessagePredicate, + listener: (msg: IoMessage) => MessageListenerResultOrPromise, + ): () => void { return this.registry.once(toMatcher(selector), listener); } - public rewrite(code: IoMessageCode, formatter: (msg: IoMessage) => string, level?: IoMessageLevel): () => void { - return this.registry.on(byCode(code), (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + public rewrite( + selector: IMessageMatcher | IoMessageCode | MessagePredicate, + formatter: (msg: IoMessage) => string, + level?: IoMessageLevel, + ): () => void { + return this.registry.on(toMatcher(selector), (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); } - public rewriteOnce(code: IoMessageCode, formatter: (msg: IoMessage) => string, level?: IoMessageLevel): () => void { - return this.registry.once(byCode(code), (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + public rewriteOnce( + selector: IMessageMatcher | IoMessageCode | MessagePredicate, + formatter: (msg: IoMessage) => string, + level?: IoMessageLevel, + ): () => void { + return this.registry.once(toMatcher(selector), (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); } - public respond(code: IoMessageCode, value: unknown, suppressQuestion = true): () => void { + public respond(selector: IRequestMatcher | IoMessageCode, value: unknown, options: RespondOptions = {}): () => void { + const suppressQuestion = options.suppressQuestion ?? true; // Only answer requests; on a notification code there's nothing to answer, so // leave it untouched rather than suppress it (which would drop the message). - return this.registry.on(byCode(code), (msg) => + return this.registry.on(toMatcher(selector), (msg) => 'defaultResponse' in msg ? { respond: value, preventDefault: suppressQuestion } : undefined); } - public respondOnce(code: IoMessageCode, value: unknown, suppressQuestion = true): () => void { - return this.registry.once(byCode(code), (msg) => + public respondOnce(selector: IRequestMatcher | IoMessageCode, value: unknown, options: RespondOptions = {}): () => void { + const suppressQuestion = options.suppressQuestion ?? true; + return this.registry.once(toMatcher(selector), (msg) => 'defaultResponse' in msg ? { respond: value, preventDefault: suppressQuestion } : undefined); } } @@ -231,8 +279,14 @@ class ListeningIoHost implements IoHostWithListeners { * 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: IoMessageCode | MessagePredicate): MessagePredicate { - return typeof selector === 'string' ? byCode(selector) : selector; +function toMatcher(selector: IMessageMatcher | IoMessageCode | MessagePredicate): MessagePredicate { + if (typeof selector === 'string') { + return byCode(selector); + } + if (typeof selector === 'function') { + return selector; + } + return (msg) => selector.is(msg); } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts index 170375dfd..f7fec3217 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts @@ -1,5 +1,4 @@ -import type { IoMessage, IoMessageLevel } from '../io-message'; -import type { IoMessageMaker, IoRequestMaker } from './message-maker'; +import type { IoMessage, IoMessageLevel, IMessageMatcher, IRequestMatcher } from '../io-message'; /** * The result a message listener may return to influence how a message is handled. @@ -66,15 +65,12 @@ export type MessageListenerResultOrPromise = void | MessageListenerResult | Prom /** * Selects which messages a listener applies to. * - * Either a message/request *maker* — the listener fires for messages with that - * maker's `code` — or a custom *predicate* over the message. A maker's `.is` - * type guard (e.g. `IO.CDK_TOOLKIT_I7010.is`) is a convenient predicate, but any - * `(msg) => boolean` works (e.g. to match a family of codes, or on the message - * level). Use `matchAny` to combine several selectors. + * Either an `IMessageMatcher` — the makers implement this, so a maker fires for + * its own messages — or a custom *predicate* over the message (e.g. to match a + * family of codes, or on the message level). Use `matchAny` to combine several. */ export type MessageSelector = - | IoMessageMaker - | IoRequestMaker + | IMessageMatcher | ((msg: IoMessage) => boolean); /** @@ -144,7 +140,7 @@ export class ListenerRegistry { * selector. Returns a function that removes the listener again. */ public on( - selector: IoMessageMaker | IoRequestMaker | ((msg: IoMessage) => msg is IoMessage), + selector: IMessageMatcher | ((msg: IoMessage) => msg is IoMessage), listener: (msg: IoMessage) => MessageListenerResultOrPromise, ): () => void; public on( @@ -160,7 +156,7 @@ export class ListenerRegistry { * invoked once. */ public once( - selector: IoMessageMaker | IoRequestMaker | ((msg: IoMessage) => msg is IoMessage), + selector: IMessageMatcher | ((msg: IoMessage) => msg is IoMessage), listener: (msg: IoMessage) => MessageListenerResultOrPromise, ): () => void; public once( @@ -177,22 +173,22 @@ export class ListenerRegistry { * that returns `{ message, level? }`. */ public rewrite( - code: IoMessageMaker | IoRequestMaker, + matcher: IMessageMatcher, formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void { - return this.on(code, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + return this.on(matcher, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); } /** * Like `rewrite`, but the formatter is removed after it has been applied once. */ public rewriteOnce( - code: IoMessageMaker | IoRequestMaker, + matcher: IMessageMatcher, formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void { - return this.once(code, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + return this.once(matcher, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); } /** @@ -203,15 +199,15 @@ export class ListenerRegistry { * @param suppressQuestion - whether to also suppress surfacing the question * text. Defaults to `true` (answer silently). */ - public respond(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { - return this.add({ once: false, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(code) }); + public respond(matcher: IRequestMatcher, value: U, suppressQuestion = true): () => void { + return this.add({ once: false, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(matcher) }); } /** * Like `respond`, but the answer is given only once and then removed. */ - public respondOnce(code: IoRequestMaker, value: U, suppressQuestion = true): () => void { - return this.add({ once: true, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(code) }); + public respondOnce(matcher: IRequestMatcher, value: U, suppressQuestion = true): () => void { + return this.add({ once: true, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(matcher) }); } /** @@ -313,15 +309,14 @@ export class ListenerRegistry { /** * Convert a `MessageSelector` into a predicate that decides whether a listener - * applies to a message. A maker matches messages carrying its `code`; a - * predicate (e.g. a maker's `.is`, or any `(msg) => boolean`) is used as-is. + * applies to a message. A matcher uses its `is` type guard; a predicate (any + * `(msg) => boolean`) is used as-is. */ export function messageMatcher(selector: MessageSelector): (msg: IoMessage) => boolean { if (typeof selector === 'function') { return selector; } - const { code } = selector; - return (msg) => msg.code === code; + return (msg) => selector.is(msg); } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts index 4eae4b95a..307cb80ec 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts @@ -1,4 +1,4 @@ -import type { IoMessage, IoMessageCode, IoMessageLevel } from '../io-message'; +import type { IoMessage, IoRequest, IMessageMatcher, IRequestMatcher, IoMessageCode, IoMessageLevel } from '../io-message'; import type { ActionLessMessage, ActionLessRequest } from './io-helper'; /** @@ -40,7 +40,7 @@ interface MessageInfo extends CodeInfo { /** * An interface that can produce messages for a specific code. */ -export interface IoMessageMaker extends MessageInfo { +export interface IoMessageMaker extends MessageInfo, IMessageMatcher { /** * Create a message for this code, with or without payload. */ @@ -109,7 +109,7 @@ interface RequestInfo extends CodeInfo { /** * An interface that can produce requests for a specific code. */ -export interface IoRequestMaker extends MessageInfo { +export interface IoRequestMaker extends MessageInfo, IRequestMatcher { /** * Create a message for this code, with or without payload. */ @@ -122,7 +122,7 @@ export interface IoRequestMaker extends MessageInfo { /** * Returns whether the given `IoMessage` instance matches this request definition */ - is(x: IoMessage): x is IoMessage; + is(x: IoMessage): x is IoRequest; } /** @@ -142,7 +142,7 @@ function request(level: IoMessageLevel, deta ...details, level, req: maker as any, - is: (m): m is IoMessage => m.code === details.code, + is: (m): m is IoRequest => m.code === details.code, }; } @@ -172,6 +172,6 @@ export function question(details: CodeInfo): IoRequestMaker { ...details, level, req: maker as any, - is: (m): m is IoMessage => m.code === details.code, + is: (m): m is IoRequest => m.code === details.code, }; } diff --git a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts index 448660092..a88a1836e 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts @@ -1,4 +1,4 @@ -import type { IIoHost, IoMessage, IoRequest } from '../../../lib/api/io'; +import type { IIoHost, IMessageMatcher, IoMessage, IoMessageCode, IoRequest } from '../../../lib/api/io'; import { withListeners } from '../../../lib/api/io'; /** @@ -22,8 +22,8 @@ class RecordingIoHost implements IIoHost { } } -const I2901 = 'CDK_TOOLKIT_I2901'; // list result, payload has `stacks` -const I7010 = 'CDK_TOOLKIT_I7010'; // destroy confirmation request (boolean) +const I2901: IoMessageCode = 'CDK_TOOLKIT_I2901'; // list result, payload has `stacks` +const I7010: IoMessageCode = 'CDK_TOOLKIT_I7010'; // destroy confirmation request (boolean) function notification(over: Partial> = {}): IoMessage { return { @@ -145,6 +145,24 @@ describe('withListeners', () => { expect(fn).toHaveBeenCalledTimes(1); }); + + test('matches on a matcher and delivers the payload typed', async () => { + const host = withListeners(inner); + // A matcher carries the payload type, so `msg.data` is `{ stacks }` here + // without a cast (the point of the matcher overload). + const matcher: IMessageMatcher<{ stacks: unknown[] }> = { + is: (m): m is IoMessage<{ stacks: unknown[] }> => m.code === I2901, + }; + const seen: number[] = []; + host.on(matcher, (m) => { + seen.push(m.data.stacks.length); + }); + + await host.notify(notification()); + await host.notify(notification({ code: 'CDK_TOOLKIT_I0001' })); + + expect(seen).toEqual([0]); + }); }); describe('once', () => { @@ -271,7 +289,7 @@ describe('withListeners', () => { test('respond with suppressQuestion=false surfaces the question but still answers', async () => { const host = withListeners(inner); - host.respond(I7010, true, false); + host.respond(I7010, true, { suppressQuestion: false }); const answer = await host.requestResponse(request({ defaultResponse: false })); @@ -333,5 +351,14 @@ describe('withListeners', () => { expect(inner.notified).toHaveLength(1); }); + + test('respondOnce on a notification code leaves the message alone instead of suppressing it', async () => { + const host = withListeners(inner); + host.respondOnce(I2901, true); + + await host.notify(notification()); + + expect(inner.notified).toHaveLength(1); + }); }); }); From 48b3b31a89c146e4129698f1c21fd54e807856dd Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Fri, 10 Jul 2026 16:24:21 -0400 Subject: [PATCH 05/13] chore: midwork --- packages/aws-cdk/test/_helpers/io-recorder.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/aws-cdk/test/_helpers/io-recorder.test.ts b/packages/aws-cdk/test/_helpers/io-recorder.test.ts index 6282cadd6..bd67d77fb 100644 --- a/packages/aws-cdk/test/_helpers/io-recorder.test.ts +++ b/packages/aws-cdk/test/_helpers/io-recorder.test.ts @@ -46,7 +46,7 @@ describe('IoHostRecorder', () => { // Answer the request through a listener so the real `requestResponse` runs // (and is therefore observed) — the recorder never spies on it. The question // is not suppressed, so it stays in the recorded stream. - const dispose = ioHost.on({ code: 'CDK_TOOLKIT_I0000' } as any, () => ({ respond: true })); + const dispose = ioHost.on((m) => m.code === 'CDK_TOOLKIT_I0000', () => ({ respond: true })); await ioHelper.defaults.info('before'); await ioHelper.requestResponse({ @@ -122,7 +122,7 @@ describe('IoHostRecorder', () => { // Suppress a specific coded message, the way the CLI drops the synth/destroy // time lines on the destroy path. - const dispose = ioHost.on({ code: 'CDK_TOOLKIT_I9999' } as any, () => ({ preventDefault: true })); + const dispose = ioHost.on((m) => m.code === 'CDK_TOOLKIT_I9999', () => ({ preventDefault: true })); await ioHelper.notify({ time: new Date(), level: 'info', code: 'CDK_TOOLKIT_I9999', message: 'suppressed', data: undefined }); await ioHelper.defaults.info('shown'); @@ -143,7 +143,7 @@ describe('IoHostRecorder', () => { const recorder = IoHostRecorder.create(ioHost); const ioHelper = asIoHelper(ioHost, 'destroy'); - const dispose = ioHost.rewrite({ code: 'CDK_TOOLKIT_I9998' } as any, () => 'rewritten by listener'); + const dispose = ioHost.rewrite({ is: (m) => m.code === 'CDK_TOOLKIT_I9998' } as any, () => 'rewritten by listener'); await ioHelper.notify({ time: new Date(), level: 'info', code: 'CDK_TOOLKIT_I9998', message: 'original', data: undefined }); From 13bcf0a642fcd6bf792087d1ae0e94944348a17b Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Fri, 10 Jul 2026 18:00:16 -0400 Subject: [PATCH 06/13] chore: midwork --- packages/aws-cdk/test/_helpers/io-recorder.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/aws-cdk/test/_helpers/io-recorder.test.ts b/packages/aws-cdk/test/_helpers/io-recorder.test.ts index bd67d77fb..a26c66782 100644 --- a/packages/aws-cdk/test/_helpers/io-recorder.test.ts +++ b/packages/aws-cdk/test/_helpers/io-recorder.test.ts @@ -1,5 +1,6 @@ import { IoHostRecorder } from './io-recorder'; import { asIoHelper, IO } from '../../lib/api-private'; +import type { IoMessage } from '../../lib/cli/io-host'; import { CliIoHost } from '../../lib/cli/io-host'; describe('IoHostRecorder', () => { @@ -143,7 +144,7 @@ describe('IoHostRecorder', () => { const recorder = IoHostRecorder.create(ioHost); const ioHelper = asIoHelper(ioHost, 'destroy'); - const dispose = ioHost.rewrite({ is: (m) => m.code === 'CDK_TOOLKIT_I9998' } as any, () => 'rewritten by listener'); + const dispose = ioHost.rewrite({ is: (m: IoMessage) => m.code === 'CDK_TOOLKIT_I9998' } as any, () => 'rewritten by listener'); await ioHelper.notify({ time: new Date(), level: 'info', code: 'CDK_TOOLKIT_I9998', message: 'original', data: undefined }); From 674b0767d3095ce01d673621f6d56444de4cc90d Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Mon, 13 Jul 2026 14:26:16 -0400 Subject: [PATCH 07/13] chore: midwork --- .../toolkit-lib/lib/api/io/listeners.ts | 18 +++++++++++++++++- .../toolkit-lib/test/api/io/listeners.test.ts | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts index b73276034..959cd5a0c 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts @@ -185,7 +185,23 @@ export interface IoHostWithListeners extends IIoHost { * ``` */ export function withListeners(host: T): T & IoHostWithListeners { - return new ListeningIoHost(host) as unknown as T & IoHostWithListeners; + const wrapper = new ListeningIoHost(host); + + // The wrapper owns `notify`/`requestResponse` (so listeners run) and the + // listener methods; any other member the caller reads falls through to the + // wrapped host, so the returned value genuinely behaves as `T & ...`. + return new Proxy(wrapper, { + get(target, prop, receiver) { + if (prop in target) { + return Reflect.get(target, prop, receiver); + } + const value = (host as any)[prop]; + return typeof value === 'function' ? value.bind(host) : value; + }, + has(target, prop) { + return prop in target || prop in (host as object); + }, + }) as unknown as T & IoHostWithListeners; } /** diff --git a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts index a88a1836e..b3a38d9c1 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts @@ -66,6 +66,23 @@ describe('withListeners', () => { expect(inner.notified).toEqual([msg]); }); + test('the wrapped host keeps the inner host\'s own members (fields and methods)', () => { + class CustomHost extends RecordingIoHost { + public readonly label = 'custom'; + public greet(): string { + return `hello from ${this.label}`; + } + } + const host = withListeners(new CustomHost()); + + // The return type is `CustomHost & IoHostWithListeners`, and the inner + // host's own field and method are reachable through the wrapper at runtime. + expect(host.label).toBe('custom'); + expect(host.greet()).toBe('hello from custom'); + // ...alongside the listener methods. + expect(typeof host.on).toBe('function'); + }); + describe('on', () => { test('runs the listener for a matching code and forwards the message', async () => { const host = withListeners(inner); From cac287d3b3fd1f4b676186c695ebbc9482a6c3e1 Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Tue, 14 Jul 2026 09:59:35 -0400 Subject: [PATCH 08/13] chore: midwork --- .../@aws-cdk/toolkit-lib/lib/api/io/listeners.ts | 10 ++++++---- .../lib/api/io/private/listener-registry.ts | 10 ++++++---- .../toolkit-lib/test/api/io/listeners.test.ts | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts index 959cd5a0c..74b265219 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts @@ -69,7 +69,9 @@ export interface IoHostWithListeners extends IIoHost { * @example * ```ts * // A predicate matches a family of messages, e.g. every warning: - * const dispose = host.on((msg) => msg.level === 'warn', (msg) => { ... }); + * const dispose = host.on((msg) => msg.level === 'warn', (msg) => { + * warnings.push(msg.message); + * }); * ``` */ on( @@ -143,9 +145,9 @@ export interface IoHostWithListeners extends IIoHost { * 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. + * By default the question is answered silently; pass + * `{ suppressQuestion: false }` to still surface the question while answering + * it. * * @example * ```ts diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts index f7fec3217..4323d0826 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts @@ -259,10 +259,8 @@ export class ListenerRegistry { continue; } - // Listeners may be async; await each one before running the next so the - // cumulative effect on the message stays order-deterministic. - const result = await listener.fn(current); - + // Remove a `once` listener before the await, so a concurrent `apply` + // (e.g. parallel stacks on one host) can't fire it a second time. if (listener.once) { const index = this.listeners.indexOf(listener); if (index >= 0) { @@ -270,6 +268,10 @@ export class ListenerRegistry { } } + // Listeners may be async; await each one before running the next so the + // cumulative effect on the message stays order-deterministic. + const result = await listener.fn(current); + if (result) { if (result.message !== undefined) { current = { ...current, message: result.message }; diff --git a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts index b3a38d9c1..0203f87af 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts @@ -204,6 +204,20 @@ describe('withListeners', () => { expect(fn).toHaveBeenCalledTimes(1); }); + + test('fires only once even when two messages are handled concurrently', async () => { + const host = withListeners(inner); + let calls = 0; + // The async listener yields, so both `notify` calls overlap inside it. + host.once(I2901, async () => { + calls++; + await new Promise((r) => setTimeout(r, 5)); + }); + + await Promise.all([host.notify(notification()), host.notify(notification())]); + + expect(calls).toBe(1); + }); }); describe('rewrite', () => { From 61968618a2cd35b14a30d8bceed99f5578488b0c Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Tue, 14 Jul 2026 11:47:37 -0400 Subject: [PATCH 09/13] chore: midwork --- packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts index 0203f87af..e3cc634c2 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts @@ -214,6 +214,7 @@ describe('withListeners', () => { await new Promise((r) => setTimeout(r, 5)); }); + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism -- fixed pair, to force overlap await Promise.all([host.notify(notification()), host.notify(notification())]); expect(calls).toBe(1); From 7917b0a5231ebe805ebf02c03b9b738e64639b61 Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Tue, 14 Jul 2026 14:33:30 -0400 Subject: [PATCH 10/13] chore: midwork --- .../toolkit-lib/lib/api/io/listeners.ts | 14 +++------ .../lib/api/io/private/listener-registry.ts | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts index 74b265219..e726b16ff 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts @@ -267,7 +267,7 @@ class ListeningIoHost implements IoHostWithListeners { formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void { - return this.registry.on(toMatcher(selector), (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + return this.registry.rewrite(toMatcher(selector), formatter, level); } public rewriteOnce( @@ -275,21 +275,15 @@ class ListeningIoHost implements IoHostWithListeners { formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void { - return this.registry.once(toMatcher(selector), (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + return this.registry.rewriteOnce(toMatcher(selector), formatter, level); } public respond(selector: IRequestMatcher | IoMessageCode, value: unknown, options: RespondOptions = {}): () => void { - const suppressQuestion = options.suppressQuestion ?? true; - // Only answer requests; on a notification code there's nothing to answer, so - // leave it untouched rather than suppress it (which would drop the message). - return this.registry.on(toMatcher(selector), (msg) => - 'defaultResponse' in msg ? { respond: value, preventDefault: suppressQuestion } : undefined); + return this.registry.respond(toMatcher(selector), value, options.suppressQuestion); } public respondOnce(selector: IRequestMatcher | IoMessageCode, value: unknown, options: RespondOptions = {}): () => void { - const suppressQuestion = options.suppressQuestion ?? true; - return this.registry.once(toMatcher(selector), (msg) => - 'defaultResponse' in msg ? { respond: value, preventDefault: suppressQuestion } : undefined); + return this.registry.respondOnce(toMatcher(selector), value, options.suppressQuestion); } } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts index 4323d0826..673054a4c 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts @@ -1,4 +1,4 @@ -import type { IoMessage, IoMessageLevel, IMessageMatcher, IRequestMatcher } from '../io-message'; +import type { IoMessage, IoMessageLevel, IMessageMatcher } from '../io-message'; /** * The result a message listener may return to influence how a message is handled. @@ -172,23 +172,25 @@ export class ListenerRegistry { * optionally also overriding the level. Syntactic sugar for an `on` listener * that returns `{ message, level? }`. */ - public rewrite( - matcher: IMessageMatcher, - formatter: (msg: IoMessage) => string, + public rewrite( + selector: MessageSelector, + formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void { - return this.on(matcher, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + const fn = (msg: IoMessage) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) }); + return this.add({ once: false, fn, matches: messageMatcher(selector) }); } /** * Like `rewrite`, but the formatter is removed after it has been applied once. */ - public rewriteOnce( - matcher: IMessageMatcher, - formatter: (msg: IoMessage) => string, + public rewriteOnce( + selector: MessageSelector, + formatter: (msg: IoMessage) => string, level?: IoMessageLevel, ): () => void { - return this.once(matcher, (msg) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) })); + const fn = (msg: IoMessage) => ({ message: formatter(msg), ...(level !== undefined ? { level } : {}) }); + return this.add({ once: true, fn, matches: messageMatcher(selector) }); } /** @@ -199,15 +201,17 @@ export class ListenerRegistry { * @param suppressQuestion - whether to also suppress surfacing the question * text. Defaults to `true` (answer silently). */ - public respond(matcher: IRequestMatcher, value: U, suppressQuestion = true): () => void { - return this.add({ once: false, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(matcher) }); + public respond(selector: MessageSelector, value: unknown, suppressQuestion = true): () => void { + const fn = (msg: IoMessage) => ('defaultResponse' in msg ? { respond: value, preventDefault: suppressQuestion } : undefined); + return this.add({ once: false, fn, matches: messageMatcher(selector) }); } /** * Like `respond`, but the answer is given only once and then removed. */ - public respondOnce(matcher: IRequestMatcher, value: U, suppressQuestion = true): () => void { - return this.add({ once: true, fn: () => ({ respond: value, preventDefault: suppressQuestion }), matches: messageMatcher(matcher) }); + public respondOnce(selector: MessageSelector, value: unknown, suppressQuestion = true): () => void { + const fn = (msg: IoMessage) => ('defaultResponse' in msg ? { respond: value, preventDefault: suppressQuestion } : undefined); + return this.add({ once: true, fn, matches: messageMatcher(selector) }); } /** From 51c0d7739b1429f9ed8d7147d5b24e28b1178cfe Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Wed, 15 Jul 2026 13:02:05 -0400 Subject: [PATCH 11/13] chore: midwork --- .../toolkit-lib/lib/api/io/private/listener-registry.ts | 9 +++++---- .../@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts | 8 +++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts index 673054a4c..9796a10b6 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/listener-registry.ts @@ -263,13 +263,14 @@ export class ListenerRegistry { continue; } - // Remove a `once` listener before the await, so a concurrent `apply` - // (e.g. parallel stacks on one host) can't fire it a second time. + // Claim a `once` listener before the await; a concurrent `apply` that + // already removed it (index < 0) skips it, so it fires exactly once. if (listener.once) { const index = this.listeners.indexOf(listener); - if (index >= 0) { - this.listeners.splice(index, 1); + if (index < 0) { + continue; } + this.listeners.splice(index, 1); } // Listeners may be async; await each one before running the next so the diff --git a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts index e3cc634c2..2c8a91afc 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts @@ -208,11 +208,13 @@ describe('withListeners', () => { test('fires only once even when two messages are handled concurrently', async () => { const host = withListeners(inner); let calls = 0; - // The async listener yields, so both `notify` calls overlap inside it. - host.once(I2901, async () => { - calls++; + // An async listener ahead of the `once` makes both notifies overlap. + host.on(I2901, async () => { await new Promise((r) => setTimeout(r, 5)); }); + host.once(I2901, () => { + calls++; + }); // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism -- fixed pair, to force overlap await Promise.all([host.notify(notification()), host.notify(notification())]); From 2e8248c1ff1d6e2a152dd195ab05059083a4d1a0 Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Wed, 15 Jul 2026 14:07:59 -0400 Subject: [PATCH 12/13] chore: midwork --- .../toolkit-lib/lib/api/io/listeners.ts | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts index e726b16ff..15c1a2675 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts @@ -109,7 +109,7 @@ export interface IoHostWithListeners extends IIoHost { * @example * ```ts * const dispose = host.rewrite('CDK_TOOLKIT_I2901', (msg) => - * serializeStructure((msg.data as StackDetailsPayload).stacks, true)); + * `${(msg.data as StackDetailsPayload).stacks.length} stacks`); * ``` */ rewrite( @@ -154,14 +154,30 @@ export interface IoHostWithListeners extends IIoHost { * const dispose = host.respond('CDK_TOOLKIT_I7010', true); * ``` */ - respond(matcher: IRequestMatcher, value: U, options?: RespondOptions): () => void; - respond(code: IoMessageCode, value: unknown, options?: RespondOptions): () => void; + respond( + matcher: IRequestMatcher, + 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(matcher: IRequestMatcher, value: U, options?: RespondOptions): () => void; - respondOnce(code: IoMessageCode, value: unknown, options?: RespondOptions): () => void; + respondOnce( + matcher: IRequestMatcher, + value: U, + options?: RespondOptions, + ): () => void; + respondOnce( + code: IoMessageCode, + value: unknown, + options?: RespondOptions, + ): () => void; } /** @@ -278,11 +294,19 @@ class ListeningIoHost implements IoHostWithListeners { return this.registry.rewriteOnce(toMatcher(selector), formatter, level); } - public respond(selector: IRequestMatcher | IoMessageCode, value: unknown, options: RespondOptions = {}): () => void { + public respond( + selector: IRequestMatcher | IoMessageCode, + value: unknown, + options: RespondOptions = {}, + ): () => void { return this.registry.respond(toMatcher(selector), value, options.suppressQuestion); } - public respondOnce(selector: IRequestMatcher | IoMessageCode, value: unknown, options: RespondOptions = {}): () => void { + public respondOnce( + selector: IRequestMatcher | IoMessageCode, + value: unknown, + options: RespondOptions = {}, + ): () => void { return this.registry.respondOnce(toMatcher(selector), value, options.suppressQuestion); } } From ae73a7d1fcab5d4bf52b3ef24056553a3f7e755a Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Wed, 15 Jul 2026 14:26:31 -0400 Subject: [PATCH 13/13] chore: midwork --- .../toolkit-lib/lib/api/io/listeners.ts | 20 ++----------------- .../toolkit-lib/test/api/io/listeners.test.ts | 17 ---------------- 2 files changed, 2 insertions(+), 35 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts index 15c1a2675..b1250ff27 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts @@ -202,24 +202,8 @@ export interface IoHostWithListeners extends IIoHost { * const toolkit = new Toolkit({ ioHost: host }); * ``` */ -export function withListeners(host: T): T & IoHostWithListeners { - const wrapper = new ListeningIoHost(host); - - // The wrapper owns `notify`/`requestResponse` (so listeners run) and the - // listener methods; any other member the caller reads falls through to the - // wrapped host, so the returned value genuinely behaves as `T & ...`. - return new Proxy(wrapper, { - get(target, prop, receiver) { - if (prop in target) { - return Reflect.get(target, prop, receiver); - } - const value = (host as any)[prop]; - return typeof value === 'function' ? value.bind(host) : value; - }, - has(target, prop) { - return prop in target || prop in (host as object); - }, - }) as unknown as T & IoHostWithListeners; +export function withListeners(host: IIoHost): IoHostWithListeners { + return new ListeningIoHost(host); } /** diff --git a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts index 2c8a91afc..ec501a69b 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/io/listeners.test.ts @@ -66,23 +66,6 @@ describe('withListeners', () => { expect(inner.notified).toEqual([msg]); }); - test('the wrapped host keeps the inner host\'s own members (fields and methods)', () => { - class CustomHost extends RecordingIoHost { - public readonly label = 'custom'; - public greet(): string { - return `hello from ${this.label}`; - } - } - const host = withListeners(new CustomHost()); - - // The return type is `CustomHost & IoHostWithListeners`, and the inner - // host's own field and method are reachable through the wrapper at runtime. - expect(host.label).toBe('custom'); - expect(host.greet()).toBe('hello from custom'); - // ...alongside the listener methods. - expect(typeof host.on).toBe('function'); - }); - describe('on', () => { test('runs the listener for a matching code and forwards the message', async () => { const host = withListeners(inner);