-
Notifications
You must be signed in to change notification settings - Fork 854
TypeScript apphost: un-awaited fluent chains silently drop RPC calls — auto-resolve promises in codegen #15899
Description
Summary
In TypeScript apphosts, every fluent method (e.g. withEnvironment, publishAsNpmScript) returns a *PromiseImpl wrapping an async RPC call. If users don't await the chain, the RPC calls never execute before build().run() completes. This causes env vars, annotations, etc. to silently vanish — particularly in publish/deploy mode.
Repro
const builder = await createBuilder();
await builder.addDockerComposeEnvironment('compose');
// BUG: withEnvironment RPC never fires because the chain is not awaited
builder.addNodeApp('app', './app', 'server.js')
.withHttpEndpoint({ port: 3000, env: 'PORT' })
.publishAsNpmScript({ startScriptName: 'start' })
.withEnvironment('MY_VAR', 'hello-world');
await builder.build().run();Result: MY_VAR is missing from the generated docker-compose.yaml. Adding await to the chain fixes it.
This is a particularly nasty failure mode because:
- It works fine in dev mode (the env vars are set correctly at runtime)
- It silently drops the values only in publish/deploy output
- There is no error or warning
Proposed Solution — 3 changes in the TypeScript codegen
1. Widen parameter types to accept promises
For handle-type parameters, also accept PromiseLike<T> so users don't need to await a resource before passing it as input.
Before:
publishAsStaticWebsite(options?: { apiTarget?: NodeAppResource | ViteAppResource }): ...After:
publishAsStaticWebsite(options?: { apiTarget?: NodeAppResource | ViteAppResource | PromiseLike<NodeAppResource | ViteAppResource> }): ...2. Auto-resolve promises before RPC calls
In all generated internal async methods, resolve any promise-like handle params before building rpcArgs. The internal methods are already async, so this is safe. Use the existing isPromiseLike() helper from transport.ts.
3. Track pending mutating promises and flush in build()
- Add
trackPromise()andflushPendingPromises()toAspireClientRpcinterface - Implement in
AspireClientclass with aSet<Promise<unknown>> - Pass
clientto every*PromiseImplconstructor (internal impl detail — interfaces are unchanged) - Only track mutating fluent calls (not
build(),run(),getEndpoint(), etc.) - In
_buildInternal, callawait this._client.flushPendingPromises()before the build RPC
Result
await or don't await — both work correctly. The user experience becomes: you almost never need await unless you're extracting a value (like getEndpoint()).
Files to modify
src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/transport.ts— exportisPromiseLike, add tracking to clientsrc/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs— widen types, add resolution, thread client through PromiseImpl, flush inbuild()
Notes
validateCapabilityArgsintransport.tsalready throws on promise-like values in RPC args — resolution must happen before the RPC call- The
*PromiseImplclasses are internal (only interfaces are public), so threading state through them doesn't affect the public API