Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
75ce861
feat(http-client-csharp): add playground support with browser-compati…
JoshLove-msft Mar 27, 2026
f4cbb6f
feat(playground-website): register C# emitter in playground
JoshLove-msft Mar 28, 2026
1dd337e
feat(playground): wire playground-server-url and add dev script
JoshLove-msft Mar 28, 2026
7858402
fix(playground): fix server port binding and add azure-core dependency
JoshLove-msft Mar 28, 2026
c907db1
refactor(http-client-csharp): skip virtual FS write in playground mode
JoshLove-msft Mar 28, 2026
a8d502d
feat(playground): add compiling spinner, preserve output during error…
JoshLove-msft Mar 28, 2026
d7817cd
feat(playground-server): add rate limiting and production Dockerfile
JoshLove-msft Mar 30, 2026
cd7967e
feat(playground-server): pass generator name from emitter to server
JoshLove-msft Mar 30, 2026
71ce858
Merge remote-tracking branch 'upstream/main' into feat/csharp-playground
JoshLove-msft Mar 30, 2026
da7daa8
fix: regenerate pnpm-lock.yaml with pnpm 10.30.2
JoshLove-msft Mar 30, 2026
02366fa
fix: use file: protocol for http-client-csharp and build emitter in CI
JoshLove-msft Mar 30, 2026
9ee211d
fix: use link: protocol for http-client-csharp dependency
JoshLove-msft Mar 31, 2026
3ce676d
fix: add catalog check exceptions for playground C# emitter deps
JoshLove-msft Mar 31, 2026
08669ac
fix: build http-client-csharp emitter in external integration CI
JoshLove-msft Mar 31, 2026
8afd53e
fix: remove http-client-csharp from playground-website, add upload sc…
JoshLove-msft Mar 31, 2026
8f9611b
feat(http-client-csharp): read playground server URL from globalThis
JoshLove-msft Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions eng/tsp-core/scripts/upload-csharp-emitter-package.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// @ts-check
// Standalone script to bundle and upload the http-client-csharp emitter
// to the playground package storage. Run from an ADO pipeline after
// building the emitter.
//
// This is separate from the core upload-bundler-packages.js because
// http-client-csharp is not in the pnpm workspace.

import { AzureCliCredential } from "@azure/identity";
import { createTypeSpecBundle } from "@typespec/bundler";
import { resolve } from "path";
import { join as joinUnix } from "path/posix";
import { repoRoot } from "../../common/scripts/helpers.js";

const credential = new AzureCliCredential();

// Dynamically import the uploader (it's in the bundle-uploader package which must be built first)
const { TypeSpecBundledPackageUploader } = await import(
"../../../packages/bundle-uploader/dist/src/upload-browser-package.js"
);
const { getPackageVersion } = await import(
"../../../packages/bundle-uploader/dist/src/index.js"
);

const packagePath = resolve(repoRoot, "packages/http-client-csharp");
const indexName = "typespec";
const indexVersion = await getPackageVersion(repoRoot, "@typespec/compiler");

console.log(`Bundling http-client-csharp emitter from: ${packagePath}`);
console.log(`Index version: ${indexVersion}`);

const bundle = await createTypeSpecBundle(packagePath);
const manifest = bundle.manifest;

const uploader = new TypeSpecBundledPackageUploader(credential);
await uploader.createIfNotExists();

const result = await uploader.upload(bundle);
if (result.status === "uploaded") {
console.log(`✔ Bundle for ${manifest.name}@${manifest.version} uploaded.`);
} else {
console.log(`Bundle for ${manifest.name} already exists for version ${manifest.version}.`);
}

// Update the index with the new import map entries
const existingIndex = await uploader.getIndex(indexName, indexVersion);
const importMap = { ...existingIndex?.imports };
for (const [key, value] of Object.entries(result.imports)) {
importMap[joinUnix(manifest.name, key)] = value;
}

await uploader.updateIndex(indexName, {
version: indexVersion,
imports: importMap,
});
console.log(`✔ Updated index for version ${indexVersion}.`);
12 changes: 11 additions & 1 deletion packages/http-client-csharp/emitter/src/code-model-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import { CSharpEmitterContext } from "./sdk-context.js";
import { CodeModel } from "./type/code-model.js";
import { Configuration } from "./type/configuration.js";

/**
* Serializes the code model to a JSON string with reference tracking.
* @param context - The CSharp emitter context
* @param codeModel - The code model to serialize
* @beta
*/
export function serializeCodeModel(context: CSharpEmitterContext, codeModel: CodeModel): string {
return prettierOutput(JSON.stringify(buildJson(context, codeModel), transformJSONProperties, 2));
}

/**
* Writes the code model to the output folder. Should only be used by autorest.csharp.
* @param context - The CSharp emitter context
Expand All @@ -22,7 +32,7 @@ export async function writeCodeModel(
) {
await context.program.host.writeFile(
resolvePath(outputFolder, tspOutputFileName),
prettierOutput(JSON.stringify(buildJson(context, codeModel), transformJSONProperties, 2)),
serializeCodeModel(context, codeModel),
);
}

Expand Down
212 changes: 151 additions & 61 deletions packages/http-client-csharp/emitter/src/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ import {
Program,
resolvePath,
} from "@typespec/compiler";
import fs, { statSync } from "fs";
import { dirname } from "path";
import { fileURLToPath } from "url";
import { writeCodeModel, writeConfiguration } from "./code-model-writer.js";
import { writeCodeModel, writeConfiguration, serializeCodeModel } from "./code-model-writer.js";
import {
_minSupportedDotNetSdkVersion,
configurationFileName,
Expand All @@ -34,14 +31,21 @@ import { Configuration } from "./type/configuration.js";
/**
* Look for the project root by looking up until a `package.json` is found.
* @param path Path to start looking
* @param statSyncFn The statSync function (injected to avoid top-level fs import)
*/
function findProjectRoot(path: string): string | undefined {
function findProjectRoot(
path: string,
statSyncFn: (p: string) => { isFile(): boolean },
): string | undefined {
let current = path;
while (true) {
const pkgPath = joinPaths(current, "package.json");
const stats = checkFile(pkgPath);
if (stats?.isFile()) {
return current;
try {
if (statSyncFn(pkgPath)?.isFile()) {
return current;
}
} catch {
// file doesn't exist
}
const parent = getDirectoryPath(current);
if (parent === current) {
Expand Down Expand Up @@ -107,64 +111,46 @@ export async function emitCodeModel(
// Apply optional code model update callback
const updatedRoot = updateCodeModel ? updateCodeModel(root, sdkContext) : root;

const generatedFolder = resolvePath(outputFolder, "src", "Generated");

if (!fs.existsSync(generatedFolder)) {
fs.mkdirSync(generatedFolder, { recursive: true });
}

// emit tspCodeModel.json
await writeCodeModel(sdkContext, updatedRoot, outputFolder);

const namespace = updatedRoot.name;
const configurations: Configuration = createConfiguration(options, namespace, sdkContext);

//emit configuration.json
await writeConfiguration(sdkContext, configurations, outputFolder);
const playgroundServerUrl =
options["playground-server-url"] ||
(typeof globalThis.process === "undefined"
? ((globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ ?? "http://localhost:5174")
: undefined);

const csProjFile = resolvePath(
outputFolder,
"src",
`${configurations["package-name"]}.csproj`,
);
logger.info(`Checking if ${csProjFile} exists`);

const emitterPath = options["emitter-extension-path"] ?? import.meta.url;
const projectRoot = findProjectRoot(dirname(fileURLToPath(emitterPath)));
const generatorPath = resolvePath(
projectRoot + "/dist/generator/Microsoft.TypeSpec.Generator.dll",
);
if (playgroundServerUrl) {
// Playground mode: serialize and send directly to server without writing to virtual FS
const codeModelJson = serializeCodeModel(sdkContext, updatedRoot);
const configJson = JSON.stringify(configurations, null, 2) + "\n";
await generateViaPlaygroundServer(
playgroundServerUrl,
sdkContext,
outputFolder,
codeModelJson,
configJson,
options["generator-name"],
);
} else {
// Local mode: write files and run .NET generator
await writeCodeModel(sdkContext, updatedRoot, outputFolder);
await writeConfiguration(sdkContext, configurations, outputFolder);

try {
const result = await execCSharpGenerator(sdkContext, {
generatorPath: generatorPath,
outputFolder: outputFolder,
await runLocalGenerator(sdkContext, diagnostics, {
outputFolder,
packageName: configurations["package-name"] ?? "",
generatorName: options["generator-name"],
newProject: options["new-project"] || !checkFile(csProjFile),
newProject: options["new-project"],
debug: options.debug ?? false,
emitterExtensionPath: options["emitter-extension-path"],
logger,
});
if (result.exitCode !== 0) {
const isValid = diagnostics.pipe(
await _validateDotNetSdk(sdkContext, _minSupportedDotNetSdkVersion),
);
// if the dotnet sdk is valid, the error is not dependency issue, log it as normal
if (isValid) {
throw new Error(
`Failed to generate the library. Exit code: ${result.exitCode}.\nStackTrace: \n${result.stderr}`,
);
}

if (!options["save-inputs"]) {
context.program.host.rm(resolvePath(outputFolder, tspOutputFileName));
context.program.host.rm(resolvePath(outputFolder, configurationFileName));
}
} catch (error: any) {
const isValid = diagnostics.pipe(
await _validateDotNetSdk(sdkContext, _minSupportedDotNetSdkVersion),
);
// if the dotnet sdk is valid, the error is not dependency issue, log it as normal
if (isValid) throw new Error(error, { cause: error });
}
if (!options["save-inputs"]) {
// delete
context.program.host.rm(resolvePath(outputFolder, tspOutputFileName));
context.program.host.rm(resolvePath(outputFolder, configurationFileName));
}
}
}
Expand Down Expand Up @@ -199,6 +185,7 @@ export function createConfiguration(
"generate-protocol-methods",
"generate-convenience-methods",
"emitter-extension-path",
"playground-server-url",
];
const derivedOptions = Object.fromEntries(
Object.entries(options).filter(([key]) => !skipKeys.includes(key)),
Expand Down Expand Up @@ -291,10 +278,113 @@ function validateDotNetSdkVersionCore(
}
}

function checkFile(pkgPath: string) {
/**
* Sends the code model and configuration to a playground server for C# generation.
* Used when the emitter runs in a browser environment.
*/
async function generateViaPlaygroundServer(
serverUrl: string,
sdkContext: CSharpEmitterContext,
outputFolder: string,
codeModelJson: string,
configJson: string,
generatorName: string,
): Promise<void> {
const response = await fetch(`${serverUrl}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
codeModel: codeModelJson,
configuration: configJson,
generatorName,
}),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Playground server error (${response.status}): ${errorText}`);
}

const result: { files: Array<{ path: string; content: string }> } = await response.json();

for (const file of result.files) {
await sdkContext.program.host.writeFile(resolvePath(outputFolder, file.path), file.content);
}
}

/**
* Runs the .NET generator locally via subprocess.
* Uses dynamic imports for Node.js modules (fs, path, url) to keep the
* emitter module loadable in browser environments.
*/
async function runLocalGenerator(
sdkContext: CSharpEmitterContext,
diagnostics: ReturnType<typeof createDiagnosticCollector>,
options: {
outputFolder: string;
packageName: string;
generatorName: string;
newProject: boolean;
debug: boolean;
emitterExtensionPath?: string;
logger: Logger;
},
): Promise<void> {
const fs = await import("fs");
const { dirname } = await import("path");
const { fileURLToPath } = await import("url");

const generatedFolder = resolvePath(options.outputFolder, "src", "Generated");

if (!fs.existsSync(generatedFolder)) {
fs.mkdirSync(generatedFolder, { recursive: true });
}

const csProjFile = resolvePath(
options.outputFolder,
"src",
`${options.packageName}.csproj`,
);
options.logger.info(`Checking if ${csProjFile} exists`);

const emitterPath = options.emitterExtensionPath ?? import.meta.url;
const projectRoot = findProjectRoot(dirname(fileURLToPath(emitterPath)), fs.statSync);
const generatorPath = resolvePath(
projectRoot + "/dist/generator/Microsoft.TypeSpec.Generator.dll",
);

const checkFile = (path: string) => {
try {
return fs.statSync(path);
} catch {
return undefined;
}
};

try {
return statSync(pkgPath);
} catch (error) {
return undefined;
const result = await execCSharpGenerator(sdkContext, {
generatorPath: generatorPath,
outputFolder: options.outputFolder,
generatorName: options.generatorName,
newProject: options.newProject || !checkFile(csProjFile),
debug: options.debug,
});
if (result.exitCode !== 0) {
const isValid = diagnostics.pipe(
await _validateDotNetSdk(sdkContext, _minSupportedDotNetSdkVersion),
);
// if the dotnet sdk is valid, the error is not dependency issue, log it as normal
if (isValid) {
throw new Error(
`Failed to generate the library. Exit code: ${result.exitCode}.\nStackTrace: \n${result.stderr}`,
);
}
}
} catch (error: any) {
const isValid = diagnostics.pipe(
await _validateDotNetSdk(sdkContext, _minSupportedDotNetSdkVersion),
);
// if the dotnet sdk is valid, the error is not dependency issue, log it as normal
if (isValid) throw new Error(error, { cause: error });
}
}
4 changes: 3 additions & 1 deletion packages/http-client-csharp/emitter/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "@azure-tools/typespec-client-generator-core";
import { getNamespaceFullName, Namespace, NoTarget, Type } from "@typespec/compiler";
import { Visibility } from "@typespec/http";
import { spawn, SpawnOptions } from "child_process";
import type { SpawnOptions } from "child_process";
import { CSharpEmitterContext } from "../sdk-context.js";

export async function execCSharpGenerator(
Expand All @@ -22,6 +22,7 @@ export async function execCSharpGenerator(
debug: boolean;
},
): Promise<{ exitCode: number; stderr: string; proc: any }> {
const { spawn } = await import("child_process");
const command = "dotnet";
const args = [
"--roll-forward",
Expand Down Expand Up @@ -110,6 +111,7 @@ export async function execAsync(
args: string[] = [],
options: SpawnOptions = {},
): Promise<{ exitCode: number; stdio: string; stdout: string; stderr: string; proc: any }> {
const { spawn } = await import("child_process");
const child = spawn(command, args, options);

return new Promise((resolve, reject) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/http-client-csharp/emitter/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface CSharpEmitterOptions {
"generate-protocol-methods"?: boolean;
"generate-convenience-methods"?: boolean;
"package-name"?: string;
"playground-server-url"?: string;
license?: {
name: string;
company?: string;
Expand Down Expand Up @@ -133,6 +134,12 @@ export const CSharpEmitterOptionsSchema: JSONSchemaType<CSharpEmitterOptions> =
description:
"The SDK context options that implement the `CreateSdkContextOptions` interface from the [`@azure-tools/typespec-client-generator-core`](https://www.npmjs.com/package/@azure-tools/typespec-client-generator-core) package to be used by the CSharp emitter.",
},
"playground-server-url": {
type: "string",
nullable: true,
description:
"URL of a playground server that runs the .NET generator. When set, the emitter sends the code model to this server instead of spawning a local dotnet process. Used for browser-based playground environments.",
},
},
required: [],
};
Expand Down
1 change: 1 addition & 0 deletions packages/http-client-csharp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"build:emitter": "tsc -p ./emitter/tsconfig.build.json",
"build:generator": "dotnet build ./generator",
"build": "npm run build:emitter && npm run build:generator && npm run extract-api",
"dev:playground": "npx concurrently -k -n playground,server -c cyan,green \"npx vite --config ../../packages/playground-website/vite.config.ts ../../packages/playground-website\" \"dotnet run --project ./playground-server\"",
"gen-extern-signature": "tspd --enable-experimental gen-extern-signature .",
"watch": "tsc -p ./emitter/tsconfig.build.json --watch",
"lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit",
Expand Down
2 changes: 2 additions & 0 deletions packages/http-client-csharp/playground-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bin/
obj/
Loading
Loading