diff --git a/.chronus/changes/fix-multi-client-tsconfig-src-include-2026-7-13-6-30-0.md b/.chronus/changes/fix-multi-client-tsconfig-src-include-2026-7-13-6-30-0.md new file mode 100644 index 0000000000..abbfc412c6 --- /dev/null +++ b/.chronus/changes/fix-multi-client-tsconfig-src-include-2026-7-13-6-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@azure-tools/typespec-ts" +--- + +Fix multi-client package build failures by syncing the generated `config/tsconfig.src.*.json` `include` lists with the `warp.config.yml` exports, so every client entry point is compiled and emitted to `dist` (previously warp failed with `DIST_MISSING`). diff --git a/packages/typespec-ts/src/index.ts b/packages/typespec-ts/src/index.ts index c7ab08bb3c..6340e3e2c6 100644 --- a/packages/typespec-ts/src/index.ts +++ b/packages/typespec-ts/src/index.ts @@ -461,12 +461,12 @@ export async function $onEmit(context: EmitContext) { // Generate warp.config.yml for Azure monorepo ESM packages commonBuilders.push((model) => buildWarpConfig(model, modularPackageInfo)); commonBuilders.push(buildTsConfig); - commonBuilders.push(buildTsSrcEsmConfig); - commonBuilders.push(buildTsSrcBrowserConfig); + commonBuilders.push(() => buildTsSrcEsmConfig(modularPackageInfo["exports"])); + commonBuilders.push(() => buildTsSrcBrowserConfig(modularPackageInfo["exports"])); if (option.generateReactNativeTarget) { - commonBuilders.push(buildTsSrcReactNativeConfig); + commonBuilders.push(() => buildTsSrcReactNativeConfig(modularPackageInfo["exports"])); } - commonBuilders.push(buildTsSrcCjsConfig); + commonBuilders.push(() => buildTsSrcCjsConfig(modularPackageInfo["exports"])); if (option.generateSample) { commonBuilders.push(buildTsSampleConfig); } @@ -531,6 +531,16 @@ export async function $onEmit(context: EmitContext) { // Update warp.config.yml for Azure monorepo packages updateBuilders.push((model: ClientModel) => buildWarpConfig(model, modularPackageInfo)); + // Keep the tsconfig.src.*.json `include` lists in sync with the warp + // exports so every declared client entry point is compiled and emitted + // to `dist` (otherwise warp fails the build with DIST_MISSING). + updateBuilders.push(() => buildTsSrcEsmConfig(modularPackageInfo["exports"])); + updateBuilders.push(() => buildTsSrcBrowserConfig(modularPackageInfo["exports"])); + if (option.generateReactNativeTarget) { + updateBuilders.push(() => buildTsSrcReactNativeConfig(modularPackageInfo["exports"])); + } + updateBuilders.push(() => buildTsSrcCjsConfig(modularPackageInfo["exports"])); + // If the client name changed, regenerate the README and snippets completely; // otherwise update only the API reference link in-place. if (hasReadmeFile) { diff --git a/packages/typespec-ts/src/metadata/build-ts-config.ts b/packages/typespec-ts/src/metadata/build-ts-config.ts index 9616ee498d..639490a332 100644 --- a/packages/typespec-ts/src/metadata/build-ts-config.ts +++ b/packages/typespec-ts/src/metadata/build-ts-config.ts @@ -52,10 +52,38 @@ export function buildTsConfig(model: ClientModel) { }; } +/** + * Computes the `include` list for the `config/tsconfig.src.*.json` files. + * + * The generated `warp.config.yml` exposes every client's entry point as a + * separate public subpath export (e.g. `./devCenter` -> `./src/devCenter/index.ts`). + * warp validates those declared exports against the emitted `dist` output, so + * each exported source file must be a TypeScript project input; otherwise the + * per-client barrels are never emitted and the build fails with `DIST_MISSING`. + * + * We therefore derive the `include` list from the same `exports` map used to + * build `warp.config.yml`, keeping the two in sync. The root `../src/index.ts` + * is always included as a fallback. + */ +export function getSrcIncludePaths(exports?: Record): string[] { + const includes = new Set(["../src/index.ts"]); + if (exports) { + for (const value of Object.values(exports)) { + if (typeof value === "string" && value.endsWith(".ts")) { + // Export values are relative to the package root (e.g. "./src/foo/index.ts"), + // whereas tsconfig.src.*.json live in the `config/` subfolder, so they are + // referenced as "../src/foo/index.ts". + includes.add(value.replace(/^\.\//, "../")); + } + } + } + return Array.from(includes); +} + /** * Builds config/tsconfig.src.esm.json — extends eng/tsconfigs/src.esm.json */ -export function buildTsSrcEsmConfig() { +export function buildTsSrcEsmConfig(exports?: Record) { return { path: "config/tsconfig.src.esm.json", content: JSON.stringify( @@ -64,7 +92,7 @@ export function buildTsSrcEsmConfig() { compilerOptions: { resolveJsonModule: true, }, - include: ["../src/index.ts"], + include: getSrcIncludePaths(exports), }, null, 2, @@ -75,7 +103,7 @@ export function buildTsSrcEsmConfig() { /** * Builds config/tsconfig.src.browser.json — extends eng/tsconfigs/src.browser.json */ -export function buildTsSrcBrowserConfig() { +export function buildTsSrcBrowserConfig(exports?: Record) { return { path: "config/tsconfig.src.browser.json", content: JSON.stringify( @@ -84,7 +112,7 @@ export function buildTsSrcBrowserConfig() { compilerOptions: { resolveJsonModule: true, }, - include: ["../src/index.ts"], + include: getSrcIncludePaths(exports), }, null, 2, @@ -95,7 +123,7 @@ export function buildTsSrcBrowserConfig() { /** * Builds config/tsconfig.src.react-native.json — extends eng/tsconfigs/src.react-native.json */ -export function buildTsSrcReactNativeConfig() { +export function buildTsSrcReactNativeConfig(exports?: Record) { return { path: "config/tsconfig.src.react-native.json", content: JSON.stringify( @@ -104,7 +132,7 @@ export function buildTsSrcReactNativeConfig() { compilerOptions: { resolveJsonModule: true, }, - include: ["../src/index.ts"], + include: getSrcIncludePaths(exports), }, null, 2, @@ -115,7 +143,7 @@ export function buildTsSrcReactNativeConfig() { /** * Builds config/tsconfig.src.cjs.json — extends eng/tsconfigs/src.cjs.json */ -export function buildTsSrcCjsConfig() { +export function buildTsSrcCjsConfig(exports?: Record) { return { path: "config/tsconfig.src.cjs.json", content: JSON.stringify( @@ -124,7 +152,7 @@ export function buildTsSrcCjsConfig() { compilerOptions: { resolveJsonModule: true, }, - include: ["../src/index.ts"], + include: getSrcIncludePaths(exports), }, null, 2, diff --git a/packages/typespec-ts/test-next/unit/metadata/ts-config.test.ts b/packages/typespec-ts/test-next/unit/metadata/ts-config.test.ts new file mode 100644 index 0000000000..76da8655a9 --- /dev/null +++ b/packages/typespec-ts/test-next/unit/metadata/ts-config.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; + +import { + buildTsSrcBrowserConfig, + buildTsSrcCjsConfig, + buildTsSrcEsmConfig, + buildTsSrcReactNativeConfig, + getSrcIncludePaths, +} from "../../../src/metadata/build-ts-config.js"; + +describe("tsconfig.src.*.json include generation", () => { + it("defaults to the root entry point when no exports are provided", () => { + expect(getSrcIncludePaths()).toEqual(["../src/index.ts"]); + expect(getSrcIncludePaths({})).toEqual(["../src/index.ts"]); + }); + + it("keeps the include list in sync with the warp exports for multi-client packages", () => { + // Mirrors the exports map produced for a multi-client package. + const exports = { + ".": "./src/index.ts", + "./devCenter": "./src/devCenter/index.ts", + "./devCenter/api": "./src/devCenter/api/index.ts", + "./devBoxes": "./src/devBoxes/index.ts", + "./devBoxes/api": "./src/devBoxes/api/index.ts", + "./deploymentEnvironments": "./src/deploymentEnvironments/index.ts", + "./deploymentEnvironments/api": "./src/deploymentEnvironments/api/index.ts", + "./models": "./src/models/index.ts", + }; + + const include = getSrcIncludePaths(exports); + + // Every per-client barrel must be a compilation input so it is emitted to dist. + expect(include).toContain("../src/index.ts"); + expect(include).toContain("../src/devCenter/index.ts"); + expect(include).toContain("../src/devBoxes/index.ts"); + expect(include).toContain("../src/deploymentEnvironments/index.ts"); + // The deeper api/model barrels are exported too, so they are included as well. + expect(include).toContain("../src/devCenter/api/index.ts"); + expect(include).toContain("../src/models/index.ts"); + }); + + it("ignores non-TypeScript export values (e.g. package.json)", () => { + const include = getSrcIncludePaths({ + "./package.json": "./package.json", + ".": "./src/index.ts", + }); + expect(include).toEqual(["../src/index.ts"]); + }); + + it("does not emit duplicate include entries", () => { + const include = getSrcIncludePaths({ + ".": "./src/index.ts", + "./alias": "./src/index.ts", + }); + expect(include).toEqual(["../src/index.ts"]); + }); + + it("propagates the exports into each per-target tsconfig", () => { + const exports = { + ".": "./src/index.ts", + "./devCenter": "./src/devCenter/index.ts", + }; + + for (const build of [ + buildTsSrcEsmConfig, + buildTsSrcBrowserConfig, + buildTsSrcReactNativeConfig, + buildTsSrcCjsConfig, + ]) { + const result = build(exports); + const parsed = JSON.parse(result.content); + expect(parsed.include).toEqual(["../src/index.ts", "../src/devCenter/index.ts"]); + } + }); +});