Skip to content

Decide TypeScript AppHost top-level await behavior for CommonJS package roots #16892

@maddymontaquila

Description

@maddymontaquila

Summary

aspire init for a TypeScript AppHost currently scaffolds apphost.ts with top-level await:

const builder = await createBuilder();
await builder.build().run();

That works when the AppHost is in an ESM package scope, but it fails in CommonJS/brownfield repos where the nearest package.json omits "type": "module" or explicitly uses CommonJS. In those repos, Node/tsx classify .ts according to the package scope, so apphost.ts is treated as CommonJS and top-level await is rejected.

We should not fix this by changing the user's root package.json to "type": "module"; that can break existing monorepos and app/tooling assumptions.

Observed impact

A React CommonJS turborepo initialized with aspire init needed manual changes before the generated TypeScript AppHost would run. Renaming the file to .mts made the file ESM, but the Aspire CLI did not recognize .mts as an AppHost, so the workaround had to fall back to rewriting the template to avoid top-level await.

Options

Option 1: Keep apphost.ts, remove top-level await

Generate an async main() wrapper instead:

async function main(): Promise<void> {
  const builder = await createBuilder();
  await builder.build().run();
}

main().catch((err: unknown) => {
  console.error(err);
  process.exit(1);
});

Pros:

  • Works in CommonJS and ESM package scopes.
  • Keeps current apphost.ts filename and detection behavior.
  • Lowest CLI/runtime compatibility risk.

Cons:

  • More verbose generated template.
  • Gives up the cleaner top-level-await style.

Option 2: Support an explicitly ESM AppHost file (apphost.mts)

Generate or support apphost.mts for TypeScript AppHosts that use top-level await.

Pros:

  • Preserves the clean top-level-await template.
  • ESM semantics are explicit and independent of the root package type.

Cons:

  • Requires TypeScript language support changes so .mts is scaffolded/detected/type-checked/run correctly.
  • Existing CLI flows currently look for apphost.ts; detection, aspire.config.json, templates, and tests need to be updated.

Option 3: Generate the TypeScript AppHost in a subfolder with a local ESM package boundary

Instead of placing apphost.ts at the repository root, create a subfolder such as aspire-apphost/ containing:

{
  "type": "module"
}

Then keep aspire-apphost/apphost.ts with top-level await and point aspire.config.json at that relative path.

Pros:

  • Preserves apphost.ts and top-level await.
  • Avoids changing the monorepo/root package type.
  • Gives Aspire-owned files a clearer boundary from the user's existing app/package config.

Cons / things to validate:

  • Package manager/workspace behavior for npm, pnpm, yarn, and bun.
  • Where dependencies should be installed and whether this creates a nested lockfile/package boundary users dislike.
  • .modules generation location and imports from apphost.ts.
  • aspire.config.json pathing, detection, restore, run, and watch behavior when the AppHost is below the repo root.
  • How aspireify/skills should discover the user's apps from a subfolder AppHost while preserving relative paths to services.

Recommendation

Discuss the desired product shape before changing the scaffold. If compatibility is the top priority, Option 1 is safest. If preserving top-level await is important, Option 2 or Option 3 should be implemented deliberately and tested across package managers and brownfield monorepos.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions