Skip to content

TypeScript apphost: un-awaited fluent chains silently drop RPC calls — auto-resolve promises in codegen #15899

@davidfowl

Description

@davidfowl

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() and flushPendingPromises() to AspireClientRpc interface
  • Implement in AspireClient class with a Set<Promise<unknown>>
  • Pass client to every *PromiseImpl constructor (internal impl detail — interfaces are unchanged)
  • Only track mutating fluent calls (not build(), run(), getEndpoint(), etc.)
  • In _buildInternal, call await 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 — export isPromiseLike, add tracking to client
  • src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs — widen types, add resolution, thread client through PromiseImpl, flush in build()

Notes

  • validateCapabilityArgs in transport.ts already throws on promise-like values in RPC args — resolution must happen before the RPC call
  • The *PromiseImpl classes are internal (only interfaces are public), so threading state through them doesn't affect the public API

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-polyglotIssues related to polyglot apphosts

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions