From 4301e90affca73766aecad8e052f588cf71107ad Mon Sep 17 00:00:00 2001 From: jolov Date: Sun, 12 Apr 2026 11:36:10 -0700 Subject: [PATCH 01/19] feat: enable C# emitter in playground with backend server - Split emitter into emit-generate.ts (Node.js) and emit-generate.browser.ts (browser stub) - Browser stub POSTs code model to playground server for generation - Created .NET playground server (ASP.NET Core) that runs the generator - Added Dockerfile for containerized deployment - Pipeline: ACR build + App Service deployment with managed identity - Bundle upload to blob storage for browser-side emitter loading - Website: added C# to playground with server URL configuration - Fix: explicit ValueExpression conversion in spread expressions to prevent SIGSEGV when ParameterProvider is spread into ValueExpression collections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/stages/emitter-stages.yml | 165 ++++++++++--- packages/http-client-csharp/.dockerignore | 7 + .../emitter/src/code-model-writer.ts | 12 +- .../emitter/src/emit-generate.browser.ts | 49 ++++ .../emitter/src/emit-generate.ts | 199 ++++++++++++++++ .../http-client-csharp/emitter/src/emitter.ts | 211 ++--------------- .../emitter/test/Unit/emitter.test.ts | 197 +++------------- .../test/Unit/validate-dotnet-sdk.test.ts | 118 ++++++++++ .../eng/pipeline/publish.yml | 4 + .../Providers/ScmMethodProviderCollection.cs | 2 +- .../src/Expressions/ValueExpression.cs | 3 +- packages/http-client-csharp/package.json | 5 + .../playground-server/.gitignore | 2 + .../playground-server/Dockerfile | 29 +++ .../playground-server/Program.cs | 218 ++++++++++++++++++ .../playground-server.csproj | 10 + .../playground-component/playground.tsx | 5 + .../src/components/react-pages/playground.tsx | 5 +- 18 files changed, 851 insertions(+), 390 deletions(-) create mode 100644 packages/http-client-csharp/.dockerignore create mode 100644 packages/http-client-csharp/emitter/src/emit-generate.browser.ts create mode 100644 packages/http-client-csharp/emitter/src/emit-generate.ts create mode 100644 packages/http-client-csharp/emitter/test/Unit/validate-dotnet-sdk.test.ts create mode 100644 packages/http-client-csharp/playground-server/.gitignore create mode 100644 packages/http-client-csharp/playground-server/Dockerfile create mode 100644 packages/http-client-csharp/playground-server/Program.cs create mode 100644 packages/http-client-csharp/playground-server/playground-server.csproj diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index 0ef45d4ec1e..d2b5800ac76 100644 --- a/eng/emitters/pipelines/templates/stages/emitter-stages.yml +++ b/eng/emitters/pipelines/templates/stages/emitter-stages.yml @@ -87,6 +87,24 @@ parameters: type: boolean default: false + # The npm script to run to build the emitter for playground bundling. + # Default is "build". C# uses "build:emitter" to skip the .NET generator build. + - name: PlaygroundBundleBuildScript + type: string + default: "build" + + # Path to a Dockerfile (relative to PackagePath) for the playground server. + # When set alongside UploadPlaygroundBundle, the server container is built and + # deployed to Azure Container Apps after publishing. + - name: PlaygroundServerDockerfile + type: string + default: "" + + # Azure Container Apps name for the playground server. + - name: PlaygroundServerAppName + type: string + default: "" + stages: # Build stage # Responsible for building the autorest generator and typespec emitter packages @@ -294,37 +312,29 @@ stages: npmrcPath: $(buildArtifactsPath)/packages/.npmrc registryUrl: https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ - # publish to devops feed - - pwsh: | - $packageFiles = Get-ChildItem -Path . -Filter '*.tgz' - foreach ($file in $packageFiles.Name) { - Write-Host "npm publish $file --verbose --access public" - npm publish $file --verbose --access public - } - displayName: Publish to DevOps feed - workingDirectory: $(buildArtifactsPath)/packages + - ${{ each package in parameters.Packages }}: + - ${{ if eq(package.type, 'npm') }}: + - pwsh: | + $file = Resolve-Path "${{ package.file }}" + Write-Host "npm publish $file --verbose --access public --prefix $(buildArtifactsPath)/packages" + npm publish $file --verbose --access public --prefix $(buildArtifactsPath)/packages + displayName: Publish ${{ package.name }} to internal feed + workingDirectory: $(buildArtifactsPath)/packages # If publishing publicly, also publish to npmjs.org - ${{ if eq(parameters.Publish, 'public') }}: - # publish to npmjs.org using ESRP - - task: EsrpRelease@11 - inputs: - displayName: Publish to npmjs.org - ConnectedServiceName: Azure SDK PME Managed Identity - ClientId: 5f81938c-2544-4f1f-9251-dd9de5b8a81b - DomainTenantId: 975f013f-7f24-47e8-a7d3-abc4752bf346 - UseManagedIdentity: true - KeyVaultName: kv-azuresdk-codesign - SignCertName: azure-sdk-esrp-release-certificate - Intent: PackageDistribution - ContentType: npm - FolderLocation: $(buildArtifactsPath)/packages - Owners: ${{ coalesce(variables['Build.RequestedForEmail'], 'azuresdk@microsoft.com') }} - Approvers: ${{ coalesce(variables['Build.RequestedForEmail'], 'azuresdk@microsoft.com') }} - ServiceEndpointUrl: https://api.esrp.microsoft.com - # cspell:ignore ESRPRELPACMANTEST - MainPublisher: ESRPRELPACMANTEST - + - pwsh: | + "//registry.npmjs.org/:_authToken=$(azure-sdk-npm-token)" | Out-File '.npmrc' + displayName: Authenticate .npmrc for npmjs.org + workingDirectory: $(buildArtifactsPath)/packages + - ${{ each package in parameters.Packages }}: + - ${{ if eq(package.type, 'npm') }}: + - pwsh: | + $file = Resolve-Path "${{ package.file }}" + Write-Host "npm publish $file --verbose --access public --prefix $(buildArtifactsPath)/packages" + npm publish $file --verbose --access public --prefix $(buildArtifactsPath)/packages + displayName: Publish ${{ package.name }} to npmjs.org + workingDirectory: $(buildArtifactsPath)/packages - ${{ if parameters.HasNugetPackages }}: - task: 1ES.PublishNuget@1 displayName: Publish Nuget packages @@ -347,14 +357,27 @@ stages: LanguageShortName: ${{ parameters.LanguageShortName }} - ${{ if parameters.UploadPlaygroundBundle }}: + - script: npm install -g pnpm + displayName: Install pnpm for playground bundle upload + - script: | + PUBLISHED_VERSION=$(ls $(buildArtifactsPath)/packages/*.tgz | head -1 | sed 's/.*-\([0-9].*\)\.tgz/\1/') + echo "Published version: $PUBLISHED_VERSION" + if [ -n "$PUBLISHED_VERSION" ]; then + node -e "const p=require('./package.json'); p.version='$PUBLISHED_VERSION'; require('fs').writeFileSync('./package.json', JSON.stringify(p, null, 2)+'\n')" + echo "Updated package.json version to $PUBLISHED_VERSION" + fi + displayName: Sync version from published package + workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - script: npm ci displayName: Install emitter dependencies for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - - script: npm run build + - script: npm run ${{ parameters.PlaygroundBundleBuildScript }} displayName: Build emitter for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - - script: npm install -g pnpm - displayName: Install pnpm for playground bundle upload + - task: NodeTool@0 + displayName: Install Node.js for playground bundle + inputs: + versionSpec: "22.x" - script: pnpm install --filter "@typespec/bundle-uploader..." displayName: Install bundle-uploader dependencies workingDirectory: $(Build.SourcesDirectory) @@ -369,6 +392,86 @@ stages: inlineScript: node ./eng/emitters/scripts/upload-bundled-emitter.js ${{ parameters.PackagePath }} workingDirectory: $(Build.SourcesDirectory) + - ${{ if and(parameters.UploadPlaygroundBundle, ne(parameters.PlaygroundServerDockerfile, '')) }}: + - task: AzureCLI@1 + displayName: Build and deploy playground server + inputs: + azureSubscription: "Azure SDK Engineering System" + scriptLocation: inlineScript + inlineScript: | + set -e + REGISTRY="typespecacr" + RESOURCE_GROUP="typespec" + APP_NAME="${{ parameters.PlaygroundServerAppName }}" + IMAGE="$REGISTRY.azurecr.io/$APP_NAME:$(Build.BuildId)" + + # Create ACR if it doesn't exist + if ! az acr show --name "$REGISTRY" --resource-group "$RESOURCE_GROUP" &>/dev/null; then + echo "Creating container registry: $REGISTRY" + az acr create --name "$REGISTRY" --resource-group "$RESOURCE_GROUP" --sku Basic --admin-enabled false + fi + + # Build and push Docker image + echo "Building Docker image: $IMAGE" + CONTEXT="$(Build.SourcesDirectory)/${{ parameters.PackagePath }}" + az acr build \ + --registry "$REGISTRY" \ + --image "$APP_NAME:$(Build.BuildId)" \ + --file "$CONTEXT/${{ parameters.PlaygroundServerDockerfile }}" \ + "$CONTEXT" + + # Deploy to Azure App Service + PLAN_NAME="typespec-playground-plan" + + # Create App Service plan if it doesn't exist + if ! az appservice plan show --name "$PLAN_NAME" --resource-group "$RESOURCE_GROUP" &>/dev/null; then + echo "Creating App Service plan: $PLAN_NAME" + az appservice plan create \ + --name "$PLAN_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --is-linux \ + --sku P0v3 + fi + + # Create or update Web App + if az webapp show --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" &>/dev/null; then + echo "Updating Web App: $APP_NAME" + else + echo "Creating Web App: $APP_NAME" + az webapp create \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --plan "$PLAN_NAME" \ + --container-image-name "$IMAGE" \ + --container-registry-url "https://$REGISTRY.azurecr.io" + fi + + # Ensure managed identity and ACR pull access + az webapp identity assign --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" 2>/dev/null || true + PRINCIPAL_ID=$(az webapp identity show --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" --query principalId -o tsv) + ACR_ID=$(az acr show --name "$REGISTRY" --resource-group "$RESOURCE_GROUP" --query id -o tsv) + az role assignment create --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal --role AcrPull --scope "$ACR_ID" 2>/dev/null || true + az webapp config set --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" \ + --generic-configurations '{"acrUseManagedIdentityCreds": true}' + + # Update container image + az webapp config container set \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --container-image-name "$IMAGE" \ + --container-registry-url "https://$REGISTRY.azurecr.io" + + # Configure app settings + az webapp config appsettings set \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --settings \ + WEBSITES_PORT=5174 \ + PLAYGROUND_URLS="https://typespec.io,https://tspwebsitepr.z22.web.core.windows.net,https://cadlplayground.z22.web.core.windows.net" + + echo "Deployed to https://$APP_NAME.azurewebsites.net" + workingDirectory: $(Build.SourcesDirectory) + templateContext: outputs: - output: pipelineArtifact diff --git a/packages/http-client-csharp/.dockerignore b/packages/http-client-csharp/.dockerignore new file mode 100644 index 00000000000..26ee17cadf1 --- /dev/null +++ b/packages/http-client-csharp/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +emitter/ +.tspd/ +*.md +*.tsp +package-lock.json diff --git a/packages/http-client-csharp/emitter/src/code-model-writer.ts b/packages/http-client-csharp/emitter/src/code-model-writer.ts index 994d0bad2e7..e60360c32b5 100644 --- a/packages/http-client-csharp/emitter/src/code-model-writer.ts +++ b/packages/http-client-csharp/emitter/src/code-model-writer.ts @@ -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 @@ -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), ); } diff --git a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts new file mode 100644 index 00000000000..a45f5a230c7 --- /dev/null +++ b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +// Browser implementation: sends code model to a playground server via fetch. + +import { resolvePath } from "@typespec/compiler"; +import { CSharpEmitterContext } from "./sdk-context.js"; +import type { GenerateOptions } from "./emit-generate.js"; + +export async function generate( + sdkContext: CSharpEmitterContext, + codeModelJson: string, + configJson: string, + options: GenerateOptions, +): Promise { + const serverUrl = (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__; + + if (!serverUrl) { + throw new Error( + "C# code generation requires a playground server. " + + "No server URL is configured. Set globalThis.__TYPESPEC_PLAYGROUND_SERVER_URL__ " + + "to the URL of a running C# playground server.", + ); + } + + const response = await fetch(`${serverUrl}/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + codeModel: codeModelJson, + configuration: configJson, + generatorName: options.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(options.outputFolder, file.path), + file.content, + ); + } +} diff --git a/packages/http-client-csharp/emitter/src/emit-generate.ts b/packages/http-client-csharp/emitter/src/emit-generate.ts new file mode 100644 index 00000000000..7e88c2d6e3e --- /dev/null +++ b/packages/http-client-csharp/emitter/src/emit-generate.ts @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +// Node.js implementation: runs the .NET generator locally via subprocess. + +import { + createDiagnosticCollector, + Diagnostic, + getDirectoryPath, + joinPaths, + NoTarget, + resolvePath, +} from "@typespec/compiler"; +import fs from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { + _minSupportedDotNetSdkVersion, + configurationFileName, + tspOutputFileName, +} from "./constants.js"; +import { createDiagnostic } from "./lib/lib.js"; +import { execAsync, execCSharpGenerator } from "./lib/utils.js"; +import { CSharpEmitterContext } from "./sdk-context.js"; + +export interface GenerateOptions { + outputFolder: string; + packageName: string; + generatorName: string; + newProject: boolean; + debug: boolean; + saveInputs: boolean; + emitterExtensionPath?: string; +} + +function findProjectRoot(path: string): string | undefined { + let current = path; + while (true) { + const pkgPath = joinPaths(current, "package.json"); + try { + if (fs.statSync(pkgPath)?.isFile()) { + return current; + } + } catch { + // file doesn't exist + } + const parent = getDirectoryPath(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + +function checkFile(pkgPath: string) { + try { + return fs.statSync(pkgPath); + } catch { + return undefined; + } +} + +export async function generate( + sdkContext: CSharpEmitterContext, + codeModelJson: string, + configJson: string, + options: GenerateOptions, +): Promise { + const diagnostics = createDiagnosticCollector(); + + const generatedFolder = resolvePath(options.outputFolder, "src", "Generated"); + if (!fs.existsSync(generatedFolder)) { + fs.mkdirSync(generatedFolder, { recursive: true }); + } + + // Write code model and configuration to disk for the generator + await sdkContext.program.host.writeFile( + resolvePath(options.outputFolder, tspOutputFileName), + codeModelJson, + ); + await sdkContext.program.host.writeFile( + resolvePath(options.outputFolder, configurationFileName), + configJson, + ); + + const csProjFile = resolvePath( + options.outputFolder, + "src", + `${options.packageName}.csproj`, + ); + + const emitterPath = options.emitterExtensionPath ?? import.meta.url; + const projectRoot = findProjectRoot(dirname(fileURLToPath(emitterPath))); + const generatorPath = resolvePath( + projectRoot + "/dist/generator/Microsoft.TypeSpec.Generator.dll", + ); + + try { + 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 (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 (isValid) throw new Error(error, { cause: error }); + } + + if (!options.saveInputs) { + sdkContext.program.host.rm(resolvePath(options.outputFolder, tspOutputFileName)); + sdkContext.program.host.rm(resolvePath(options.outputFolder, configurationFileName)); + } + + sdkContext.program.reportDiagnostics(diagnostics.diagnostics); +} + +/** @internal */ +export async function _validateDotNetSdk( + sdkContext: CSharpEmitterContext, + minMajorVersion: number, +): Promise<[boolean, readonly Diagnostic[]]> { + const diagnostics = createDiagnosticCollector(); + try { + const result = await execAsync("dotnet", ["--version"], { stdio: "pipe" }); + return diagnostics.wrap( + diagnostics.pipe(validateDotNetSdkVersionCore(result.stdout, minMajorVersion)), + ); + } catch (error: any) { + if (error && "code" in error && error["code"] === "ENOENT") { + diagnostics.add( + createDiagnostic({ + code: "invalid-dotnet-sdk-dependency", + messageId: "missing", + format: { + dotnetMajorVersion: `${minMajorVersion}`, + downloadUrl: "https://dotnet.microsoft.com/", + }, + target: NoTarget, + }), + ); + } + return diagnostics.wrap(false); + } +} + +function validateDotNetSdkVersionCore( + version: string, + minMajorVersion: number, +): [boolean, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + if (version) { + const dotIndex = version.indexOf("."); + const firstPart = dotIndex === -1 ? version : version.substring(0, dotIndex); + const major = Number(firstPart); + + if (isNaN(major)) { + return diagnostics.wrap(false); + } + if (major < minMajorVersion) { + diagnostics.add( + createDiagnostic({ + code: "invalid-dotnet-sdk-dependency", + messageId: "invalidVersion", + format: { + installedVersion: version, + dotnetMajorVersion: `${minMajorVersion}`, + downloadUrl: "https://dotnet.microsoft.com/", + }, + target: NoTarget, + }), + ); + return diagnostics.wrap(false); + } + return diagnostics.wrap(true); + } else { + diagnostics.add( + createDiagnostic({ + code: "general-error", + format: { message: "Cannot get the installed .NET SDK version." }, + target: NoTarget, + }), + ); + return diagnostics.wrap(false); + } +} diff --git a/packages/http-client-csharp/emitter/src/emitter.ts b/packages/http-client-csharp/emitter/src/emitter.ts index a41e196e09f..8728b440f0a 100644 --- a/packages/http-client-csharp/emitter/src/emitter.ts +++ b/packages/http-client-csharp/emitter/src/emitter.ts @@ -6,51 +6,18 @@ import { createDiagnosticCollector, Diagnostic, EmitContext, - getDirectoryPath, - joinPaths, - NoTarget, Program, - resolvePath, } from "@typespec/compiler"; -import fs, { statSync } from "fs"; -import { dirname, resolve } from "path"; -import { fileURLToPath } from "url"; -import { writeCodeModel, writeConfiguration } from "./code-model-writer.js"; -import { - _minSupportedDotNetSdkVersion, - configurationFileName, - tspOutputFileName, -} from "./constants.js"; +import { serializeCodeModel } from "./code-model-writer.js"; +import { generate } from "./emit-generate.js"; import { createModel } from "./lib/client-model-builder.js"; -import { createDiagnostic } from "./lib/lib.js"; import { LoggerLevel } from "./lib/logger-level.js"; import { Logger } from "./lib/logger.js"; -import { execAsync, execCSharpGenerator } from "./lib/utils.js"; import { CSharpEmitterOptions, resolveOptions } from "./options.js"; import { createCSharpEmitterContext, CSharpEmitterContext } from "./sdk-context.js"; import { CodeModel } from "./type/code-model.js"; 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 - */ -function findProjectRoot(path: string): string | undefined { - let current = path; - while (true) { - const pkgPath = joinPaths(current, "package.json"); - const stats = checkFile(pkgPath); - if (stats?.isFile()) { - return current; - } - const parent = getDirectoryPath(current); - if (parent === current) { - return undefined; - } - current = parent; - } -} - /** * Creates a code model by executing the full emission logic. * This function can be called by downstream emitters to generate a code model and collect diagnostics. @@ -84,11 +51,6 @@ export async function emitCodeModel( const options = resolveOptions(context); const outputFolder = context.emitterOutputDir; - // Resolve plugin paths to absolute if specified - if (options["plugins"]) { - options["plugins"] = options["plugins"].map((p) => resolve(outputFolder, p)); - } - /* set the log level. */ const logger = new Logger(program, options.logLevel ?? LoggerLevel.INFO); @@ -112,81 +74,25 @@ 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); + // Serialize code model and configuration + const codeModelJson = serializeCodeModel(sdkContext, updatedRoot); + const configJson = JSON.stringify(configurations, null, 2) + "\n"; - const csProjFile = resolvePath( + // Generate C# code via platform-specific implementation. + // In Node.js this runs the .NET generator locally. + // In the browser this sends the code model to a playground server. + await generate(sdkContext, codeModelJson, configJson, { 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", - ); - - try { - const result = await execCSharpGenerator(sdkContext, { - generatorPath: generatorPath, - outputFolder: outputFolder, - generatorName: options["generator-name"], - newProject: options["new-project"] || !checkFile(csProjFile), - debug: options.debug ?? false, - }); - 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) { - diagnostics.add( - createDiagnostic({ - code: "general-error", - format: { - message: `Failed to generate the library. Exit code: ${result.exitCode}.\n${result.stderr}`, - }, - target: NoTarget, - }), - ); - } - } - } 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) { - diagnostics.add( - createDiagnostic({ - code: "general-error", - format: { - message: `Failed to generate the library. Error: ${error.message ?? error}`, - }, - target: NoTarget, - }), - ); - } - } - if (!options["save-inputs"]) { - // delete - context.program.host.rm(resolvePath(outputFolder, tspOutputFileName)); - context.program.host.rm(resolvePath(outputFolder, configurationFileName)); - } + packageName: configurations["package-name"] ?? "", + generatorName: options["generator-name"], + newProject: options["new-project"], + debug: options.debug ?? false, + saveInputs: options["save-inputs"] ?? false, + emitterExtensionPath: options["emitter-extension-path"], + }); } } @@ -234,88 +140,3 @@ export function createConfiguration( license: sdkContext.sdkPackage.licenseInfo, }; } - -/** check the dotnet sdk installation. - * Report diagnostic if dotnet sdk is not installed or its version does not meet prerequisite - * @param sdkContext - The SDK context - * @param minVersionRequisite - The minimum required major version - * @returns A tuple containing whether the SDK is valid and any diagnostics - * @internal - */ -export async function _validateDotNetSdk( - sdkContext: CSharpEmitterContext, - minMajorVersion: number, -): Promise<[boolean, readonly Diagnostic[]]> { - const diagnostics = createDiagnosticCollector(); - try { - const result = await execAsync("dotnet", ["--version"], { stdio: "pipe" }); - return diagnostics.wrap( - diagnostics.pipe(validateDotNetSdkVersionCore(sdkContext, result.stdout, minMajorVersion)), - ); - } catch (error: any) { - if (error && "code" in error && error["code"] === "ENOENT") { - diagnostics.add( - createDiagnostic({ - code: "invalid-dotnet-sdk-dependency", - messageId: "missing", - format: { - dotnetMajorVersion: `${minMajorVersion}`, - downloadUrl: "https://dotnet.microsoft.com/", - }, - target: NoTarget, - }), - ); - } - return diagnostics.wrap(false); - } -} - -function validateDotNetSdkVersionCore( - sdkContext: CSharpEmitterContext, - version: string, - minMajorVersion: number, -): [boolean, readonly Diagnostic[]] { - const diagnostics = createDiagnosticCollector(); - if (version) { - const dotIndex = version.indexOf("."); - const firstPart = dotIndex === -1 ? version : version.substring(0, dotIndex); - const major = Number(firstPart); - - if (isNaN(major)) { - return diagnostics.wrap(false); - } - if (major < minMajorVersion) { - diagnostics.add( - createDiagnostic({ - code: "invalid-dotnet-sdk-dependency", - messageId: "invalidVersion", - format: { - installedVersion: version, - dotnetMajorVersion: `${minMajorVersion}`, - downloadUrl: "https://dotnet.microsoft.com/", - }, - target: NoTarget, - }), - ); - return diagnostics.wrap(false); - } - return diagnostics.wrap(true); - } else { - diagnostics.add( - createDiagnostic({ - code: "general-error", - format: { message: "Cannot get the installed .NET SDK version." }, - target: NoTarget, - }), - ); - return diagnostics.wrap(false); - } -} - -function checkFile(pkgPath: string) { - try { - return statSync(pkgPath); - } catch (error) { - return undefined; - } -} diff --git a/packages/http-client-csharp/emitter/test/Unit/emitter.test.ts b/packages/http-client-csharp/emitter/test/Unit/emitter.test.ts index 640d242a63e..2e0a4e7f754 100644 --- a/packages/http-client-csharp/emitter/test/Unit/emitter.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/emitter.test.ts @@ -5,6 +5,7 @@ import { TestHost } from "@typespec/compiler/testing"; import { strictEqual } from "assert"; import { statSync } from "fs"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { generate } from "../../src/emit-generate.js"; import { execAsync, execCSharpGenerator } from "../../src/lib/utils.js"; import { CSharpEmitterOptions } from "../../src/options.js"; import { CodeModel } from "../../src/type/code-model.js"; @@ -61,6 +62,10 @@ describe("$onEmit tests", () => { execAsync: vi.fn(), })); + vi.mock("../../src/emit-generate.js", () => ({ + generate: vi.fn(), + })); + vi.mock("../../src/lib/client-model-builder.js", () => ({ createModel: vi.fn().mockReturnValue([{ name: "TestNamespace" }, []]), })); @@ -139,68 +144,53 @@ describe("$onEmit tests", () => { ); }); - it("should set newProject to TRUE if .csproj file DOES NOT exist", async () => { - vi.mocked(statSync).mockImplementation(() => { - throw new Error("File not found"); - }); - - const context: EmitContext = createEmitterContext(program); - await $onEmit(context); - - expect(execCSharpGenerator).toHaveBeenCalledWith(expect.anything(), { - generatorPath: expect.any(String), - outputFolder: undefined, - generatorName: "ScmCodeModelGenerator", - newProject: true, // Ensure this is passed as true - debug: false, - }); - }); - - it("should set newProject to FALSE if .csproj file DOES exist", async () => { - vi.mocked(statSync).mockReturnValue({ isFile: () => true } as any); - + it("should pass newProject FALSE by default", async () => { const context: EmitContext = createEmitterContext(program); await $onEmit(context); - expect(execCSharpGenerator).toHaveBeenCalledWith(expect.anything(), { - generatorPath: expect.any(String), - outputFolder: undefined, - generatorName: "ScmCodeModelGenerator", - newProject: false, // Ensure this is passed as false - debug: false, - }); + expect(generate).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + expect.any(String), + expect.objectContaining({ + newProject: false, + generatorName: "ScmCodeModelGenerator", + }), + ); }); - it("should set newProject to TRUE if passed in options", async () => { - vi.mocked(statSync).mockReturnValue({ isFile: () => true } as any); - + it("should pass newProject TRUE when set in options", async () => { const context: EmitContext = createEmitterContext(program, { "new-project": true, }); await $onEmit(context); - expect(execCSharpGenerator).toHaveBeenCalledWith(expect.anything(), { - generatorPath: expect.any(String), - outputFolder: undefined, - generatorName: "ScmCodeModelGenerator", - newProject: true, // Ensure this is passed as true - debug: false, - }); - }); - it("should set newProject to FALSE if passed in options", async () => { - vi.mocked(statSync).mockReturnValue({ isFile: () => true } as any); + expect(generate).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + expect.any(String), + expect.objectContaining({ + newProject: true, + generatorName: "ScmCodeModelGenerator", + }), + ); + }); + it("should pass newProject FALSE when set in options", async () => { const context: EmitContext = createEmitterContext(program, { "new-project": false, }); await $onEmit(context); - expect(execCSharpGenerator).toHaveBeenCalledWith(expect.anything(), { - generatorPath: expect.any(String), - outputFolder: undefined, - generatorName: "ScmCodeModelGenerator", - newProject: false, // Ensure this is passed as true - debug: false, - }); + + expect(generate).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + expect.any(String), + expect.objectContaining({ + newProject: false, + generatorName: "ScmCodeModelGenerator", + }), + ); }); }); @@ -257,116 +247,3 @@ describe("emitCodeModel tests", () => { }); }); -describe("Test _validateDotNetSdk", () => { - let runner: TestHost; - let program: Program; - const minVersion = 8; - let _validateDotNetSdk: (arg0: any, arg1: number) => Promise<[boolean, readonly Diagnostic[]]>; - - beforeEach(async () => { - vi.resetModules(); - runner = await createEmitterTestHost(); - program = await typeSpecCompile( - ` - op test( - @query - @encode(DurationKnownEncoding.ISO8601) - input: duration - ): NoContentResponse; - `, - runner, - ); - // Restore all mocks before each test - vi.restoreAllMocks(); - vi.mock("../../src/lib/utils.js", () => ({ - execCSharpGenerator: vi.fn(), - execAsync: vi.fn(), - })); - - // dynamically import the module to get the $onEmit function - // we avoid importing it at the top to allow mocking of dependencies - _validateDotNetSdk = (await import("../../src/emitter.js"))._validateDotNetSdk; - }); - - it("should return false and report diagnostic when dotnet SDK is not installed.", async () => { - /* mock the scenario that dotnet SDK is not installed, so execAsync will throw exception with error ENOENT */ - const error: any = new Error("ENOENT: no such file or directory"); - error.code = "ENOENT"; - (execAsync as Mock).mockRejectedValueOnce(error); - const context = createEmitterContext(program); - const sdkContext = await createCSharpSdkContext(context); - const [result, diagnostics] = await _validateDotNetSdk(sdkContext, minVersion); - // Report collected diagnostics to program - program.reportDiagnostics(diagnostics); - expect(result).toBe(false); - strictEqual(program.diagnostics.length, 1); - strictEqual( - program.diagnostics[0].code, - "@typespec/http-client-csharp/invalid-dotnet-sdk-dependency", - ); - strictEqual( - program.diagnostics[0].message, - "The dotnet command was not found in the PATH. Please install the .NET SDK version 8 or above. Guidance for installing the .NET SDK can be found at https://dotnet.microsoft.com/.", - ); - }); - - it("should return true for installed SDK version whose major equals min supported version", async () => { - /* mock the scenario that the installed SDK version whose major equals min supported version */ - (execAsync as Mock).mockResolvedValueOnce({ - exitCode: 0, - stdio: "", - stdout: "8.0.204", - stderr: "", - proc: { pid: 0, output: "", stdout: "", stderr: "", stdin: "" }, - }); - const context = createEmitterContext(program); - const sdkContext = await createCSharpSdkContext(context); - const [result, diagnostics] = await _validateDotNetSdk(sdkContext, minVersion); - expect(result).toBe(true); - /* no diagnostics */ - strictEqual(diagnostics.length, 0); - }); - - it("should return true for installed SDK version whose major greaters than min supported version", async () => { - /* mock the scenario that the installed SDK version whose major greater than min supported version */ - (execAsync as Mock).mockResolvedValueOnce({ - exitCode: 0, - stdio: "", - stdout: "9.0.102", - stderr: "", - proc: { pid: 0, output: "", stdout: "", stderr: "", stdin: "" }, - }); - const context = createEmitterContext(program); - const sdkContext = await createCSharpSdkContext(context); - const [result, diagnostics] = await _validateDotNetSdk(sdkContext, minVersion); - expect(result).toBe(true); - /* no diagnostics */ - strictEqual(diagnostics.length, 0); - }); - - it("should return false and report diagnostic for invalid .NET SDK version", async () => { - /* mock the scenario that the installed SDK version whose major less than min supported version */ - (execAsync as Mock).mockResolvedValueOnce({ - exitCode: 0, - stdio: "", - stdout: "5.0.408", - stderr: "", - proc: { pid: 0, output: "", stdout: "", stderr: "", stdin: "" }, - }); - const context = createEmitterContext(program); - const sdkContext = await createCSharpSdkContext(context); - const [result, diagnostics] = await _validateDotNetSdk(sdkContext, minVersion); - // Report collected diagnostics to program - program.reportDiagnostics(diagnostics); - expect(result).toBe(false); - strictEqual(program.diagnostics.length, 1); - strictEqual( - program.diagnostics[0].code, - "@typespec/http-client-csharp/invalid-dotnet-sdk-dependency", - ); - strictEqual( - program.diagnostics[0].message, - "The .NET SDK found is version 5.0.408. Please install the .NET SDK 8 or above and ensure there is no global.json in the file system requesting a lower version. Guidance for installing the .NET SDK can be found at https://dotnet.microsoft.com/.", - ); - }); -}); diff --git a/packages/http-client-csharp/emitter/test/Unit/validate-dotnet-sdk.test.ts b/packages/http-client-csharp/emitter/test/Unit/validate-dotnet-sdk.test.ts new file mode 100644 index 00000000000..a3f0eb397fd --- /dev/null +++ b/packages/http-client-csharp/emitter/test/Unit/validate-dotnet-sdk.test.ts @@ -0,0 +1,118 @@ +vi.resetModules(); + +import { Diagnostic, Program } from "@typespec/compiler"; +import { TestHost } from "@typespec/compiler/testing"; +import { strictEqual } from "assert"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { execAsync } from "../../src/lib/utils.js"; +import { + createCSharpSdkContext, + createEmitterContext, + createEmitterTestHost, + typeSpecCompile, +} from "./utils/test-util.js"; + +describe("Test _validateDotNetSdk", () => { + let runner: TestHost; + let program: Program; + const minVersion = 8; + let _validateDotNetSdk: (arg0: any, arg1: number) => Promise<[boolean, readonly Diagnostic[]]>; + + beforeEach(async () => { + vi.resetModules(); + runner = await createEmitterTestHost(); + program = await typeSpecCompile( + ` + op test( + @query + @encode(DurationKnownEncoding.ISO8601) + input: duration + ): NoContentResponse; + `, + runner, + ); + // Restore all mocks before each test + vi.restoreAllMocks(); + vi.mock("../../src/lib/utils.js", () => ({ + execCSharpGenerator: vi.fn(), + execAsync: vi.fn(), + })); + + // dynamically import the module to get the _validateDotNetSdk function + _validateDotNetSdk = (await import("../../src/emit-generate.js"))._validateDotNetSdk; + }); + + it("should return false and report diagnostic when dotnet SDK is not installed.", async () => { + const error: any = new Error("ENOENT: no such file or directory"); + error.code = "ENOENT"; + (execAsync as Mock).mockRejectedValueOnce(error); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const [result, diagnostics] = await _validateDotNetSdk(sdkContext, minVersion); + program.reportDiagnostics(diagnostics); + expect(result).toBe(false); + strictEqual(program.diagnostics.length, 1); + strictEqual( + program.diagnostics[0].code, + "@typespec/http-client-csharp/invalid-dotnet-sdk-dependency", + ); + strictEqual( + program.diagnostics[0].message, + "The dotnet command was not found in the PATH. Please install the .NET SDK version 8 or above. Guidance for installing the .NET SDK can be found at https://dotnet.microsoft.com/.", + ); + }); + + it("should return true for installed SDK version whose major equals min supported version", async () => { + (execAsync as Mock).mockResolvedValueOnce({ + exitCode: 0, + stdio: "", + stdout: "8.0.204", + stderr: "", + proc: { pid: 0, output: "", stdout: "", stderr: "", stdin: "" }, + }); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const [result, diagnostics] = await _validateDotNetSdk(sdkContext, minVersion); + expect(result).toBe(true); + strictEqual(diagnostics.length, 0); + }); + + it("should return true for installed SDK version whose major greaters than min supported version", async () => { + (execAsync as Mock).mockResolvedValueOnce({ + exitCode: 0, + stdio: "", + stdout: "9.0.102", + stderr: "", + proc: { pid: 0, output: "", stdout: "", stderr: "", stdin: "" }, + }); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const [result, diagnostics] = await _validateDotNetSdk(sdkContext, minVersion); + expect(result).toBe(true); + strictEqual(diagnostics.length, 0); + }); + + it("should return false and report diagnostic for invalid .NET SDK version", async () => { + (execAsync as Mock).mockResolvedValueOnce({ + exitCode: 0, + stdio: "", + stdout: "5.0.408", + stderr: "", + proc: { pid: 0, output: "", stdout: "", stderr: "", stdin: "" }, + }); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const [result, diagnostics] = await _validateDotNetSdk(sdkContext, minVersion); + program.reportDiagnostics(diagnostics); + expect(result).toBe(false); + strictEqual(program.diagnostics.length, 1); + strictEqual( + program.diagnostics[0].code, + "@typespec/http-client-csharp/invalid-dotnet-sdk-dependency", + ); + strictEqual( + program.diagnostics[0].message, + "The .NET SDK found is version 5.0.408. Please install the .NET SDK 8 or above and ensure there is no global.json in the file system requesting a lower version. Guidance for installing the .NET SDK can be found at https://dotnet.microsoft.com/.", + ); + }); +}); diff --git a/packages/http-client-csharp/eng/pipeline/publish.yml b/packages/http-client-csharp/eng/pipeline/publish.yml index e369e98bebd..c4adb784e39 100644 --- a/packages/http-client-csharp/eng/pipeline/publish.yml +++ b/packages/http-client-csharp/eng/pipeline/publish.yml @@ -64,6 +64,10 @@ extends: LanguageShortName: "csharp" HasNugetPackages: true CadlRanchName: "@typespec/http-client-csharp" + UploadPlaygroundBundle: true + PlaygroundBundleBuildScript: "build:emitter" + PlaygroundServerDockerfile: "playground-server/Dockerfile" + PlaygroundServerAppName: "csharp-playground-server" AdditionalInitializeSteps: - task: UseDotNet@2 inputs: diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmMethodProviderCollection.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmMethodProviderCollection.cs index 73148e9ed9c..6e63a330e24 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmMethodProviderCollection.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmMethodProviderCollection.cs @@ -956,7 +956,7 @@ private ScmMethodProvider BuildProtocolMethod(MethodProvider createRequestMethod [ UsingDeclare("message", ScmCodeModelGenerator.Instance.TypeFactory.HttpMessageApi.HttpMessageType, This.Invoke(createRequestMethod.Signature, - [.. parameters]), out var message), + [.. parameters.Select(p => (ValueExpression)p)]), out var message), Return(ScmCodeModelGenerator.Instance.TypeFactory.ClientResponseApi.ToExpression().FromResponse(client .PipelineProperty.Invoke(processMessageName, [message, requestOptionsParameter], isAsync, true, extensionType: _clientPipelineExtensionsDefinition.Type))) ]; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Expressions/ValueExpression.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Expressions/ValueExpression.cs index 0e37d14cd15..0dde202a62f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Expressions/ValueExpression.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Expressions/ValueExpression.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using Microsoft.TypeSpec.Generator.Primitives; using Microsoft.TypeSpec.Generator.Providers; using Microsoft.TypeSpec.Generator.Snippets; @@ -80,7 +81,7 @@ public InvokeMethodExpression Invoke(string methodName, IReadOnlyList new InvokeMethodExpression(this, methodName, arguments); public InvokeMethodExpression Invoke(MethodSignature methodSignature) - => new InvokeMethodExpression(this, methodSignature, [.. methodSignature.Parameters]) + => new InvokeMethodExpression(this, methodSignature, [.. methodSignature.Parameters.Select(p => (ValueExpression)p)]) { CallAsAsync = methodSignature.Modifiers.HasFlag(MethodSignatureModifiers.Async) }; diff --git a/packages/http-client-csharp/package.json b/packages/http-client-csharp/package.json index 2d02f7cfdb8..7b3bbc09bb9 100644 --- a/packages/http-client-csharp/package.json +++ b/packages/http-client-csharp/package.json @@ -25,11 +25,16 @@ "default": "./dist/emitter/src/index.js" } }, + "browser": { + "./dist/emitter/src/emit-generate.js": "./dist/emitter/src/emit-generate.browser.js", + "./emitter/src/emit-generate.ts": "./emitter/src/emit-generate.browser.ts" + }, "scripts": { "clean": "rimraf ./dist ./emitter/temp && dotnet clean ./generator", "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": "npm run build:emitter && cd ../../website && npm run dev", "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", diff --git a/packages/http-client-csharp/playground-server/.gitignore b/packages/http-client-csharp/playground-server/.gitignore new file mode 100644 index 00000000000..cd42ee34e87 --- /dev/null +++ b/packages/http-client-csharp/playground-server/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/packages/http-client-csharp/playground-server/Dockerfile b/packages/http-client-csharp/playground-server/Dockerfile new file mode 100644 index 00000000000..28e85056355 --- /dev/null +++ b/packages/http-client-csharp/playground-server/Dockerfile @@ -0,0 +1,29 @@ +# Build from the http-client-csharp package root: +# docker build -f playground-server/Dockerfile -t csharp-playground-server . +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Build the generator (populates dist/generator/) +COPY generator/ generator/ +COPY eng/ eng/ +RUN dotnet build generator -c Release + +# Build the server +COPY playground-server/playground-server.csproj playground-server/ +RUN dotnet restore playground-server/playground-server.csproj +COPY playground-server/ playground-server/ +RUN dotnet publish playground-server -c Release -o /app + +# Copy generator output +RUN cp -r dist/generator /app/generator + +# Need full SDK (not just aspnet) because the server spawns `dotnet` to run the generator DLL +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime +WORKDIR /app +COPY --from=build /app . + +ENV DOTNET_ENVIRONMENT=Production +ENV GENERATOR_PATH=/app/generator/Microsoft.TypeSpec.Generator.dll + +EXPOSE 5174 +ENTRYPOINT ["dotnet", "playground-server.dll"] diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs new file mode 100644 index 00000000000..381c899a769 --- /dev/null +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.RateLimiting; + +var builder = WebApplication.CreateBuilder(args); + +var allowedOrigins = new HashSet(StringComparer.OrdinalIgnoreCase) +{ + "http://localhost:5173", // vite dev + "http://localhost:4173", // vite preview + "http://localhost:3000", + "https://typespec.io", + "https://www.typespec.io", +}; +// Add additional origins from PLAYGROUND_URLS (comma-separated) or PLAYGROUND_URL (single) +var playgroundUrls = Environment.GetEnvironmentVariable("PLAYGROUND_URLS") + ?? Environment.GetEnvironmentVariable("PLAYGROUND_URL"); +if (!string.IsNullOrEmpty(playgroundUrls)) +{ + foreach (var origin in playgroundUrls.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (Uri.TryCreate(origin, UriKind.Absolute, out var uri)) + { + allowedOrigins.Add(uri.GetLeftPart(UriPartial.Authority)); + } + } +} + +builder.Services.AddCors(); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = 429; + options.AddFixedWindowLimiter("generate", limiter => + { + limiter.PermitLimit = 10; + limiter.Window = TimeSpan.FromMinutes(1); + limiter.QueueLimit = 2; + limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + }); +}); + +var app = builder.Build(); + +app.UseCors(policy => policy + .WithOrigins([.. allowedOrigins]) + .AllowAnyMethod() + .AllowAnyHeader()); + +app.UseRateLimiter(); + +// Resolve the generator DLL path. Default: dist/generator in the http-client-csharp package. +var generatorPath = Environment.GetEnvironmentVariable("GENERATOR_PATH") + ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "dist", "generator", "Microsoft.TypeSpec.Generator.dll")); + +if (!File.Exists(generatorPath)) +{ + Console.Error.WriteLine($"WARNING: Generator DLL not found at {generatorPath}"); + Console.Error.WriteLine("Set GENERATOR_PATH environment variable to the correct path."); +} +else +{ + Console.WriteLine($"Generator DLL: {generatorPath}"); +} + +app.MapGet("/health", () => +{ + string dotnetVersion; + try + { + var psi = new ProcessStartInfo("dotnet", "--version") { RedirectStandardOutput = true, UseShellExecute = false }; + var proc = Process.Start(psi)!; + dotnetVersion = proc.StandardOutput.ReadToEnd().Trim(); + proc.WaitForExit(); + } + catch (Exception ex) { dotnetVersion = ex.Message; } + + return Results.Ok(new + { + status = "ok", + generatorFound = File.Exists(generatorPath), + generatorPath, + dotnetVersion, + runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, + os = System.Runtime.InteropServices.RuntimeInformation.OSDescription, + arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture.ToString() + }); +}); + +app.MapPost("/generate", async (HttpRequest request) => +{ + var body = await JsonSerializer.DeserializeAsync( + request.Body, GenerateJsonContext.Default.GenerateRequest); + + if (body?.CodeModel is null || body?.Configuration is null) + { + return Results.BadRequest(new { error = "Missing 'codeModel' or 'configuration' fields" }); + } + + if (!File.Exists(generatorPath)) + { + return Results.StatusCode(503); + } + + // Create a temporary working directory + var tempDir = Path.Combine(Path.GetTempPath(), "tsp-playground", Guid.NewGuid().ToString("N")); + var generatedDir = Path.Combine(tempDir, "src", "Generated"); + Directory.CreateDirectory(generatedDir); + + try + { + // Write the input files the generator expects + await File.WriteAllTextAsync(Path.Combine(tempDir, "tspCodeModel.json"), body.CodeModel); + await File.WriteAllTextAsync(Path.Combine(tempDir, "Configuration.json"), body.Configuration); + + var generatorName = body.GeneratorName ?? "ScmCodeModelGenerator"; + + // Run the .NET generator as a subprocess + Console.WriteLine($"Starting generator: dotnet --roll-forward Major {generatorPath} {tempDir} -g {generatorName} --new-project"); + Console.WriteLine($"Code model size: {body.CodeModel!.Length} chars"); + Console.WriteLine($"Configuration: {body.Configuration}"); + + var psi = new ProcessStartInfo + { + FileName = "dotnet", + ArgumentList = { "--roll-forward", "Major", generatorPath, tempDir, "-g", generatorName, "--new-project" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi)!; + + // Stream stdout/stderr to console for logging + var stderrLines = new List(); + var stdoutTask = Task.Run(async () => + { + string? line; + while ((line = await process.StandardOutput.ReadLineAsync()) != null) + { + Console.WriteLine($"[generator stdout] {line}"); + } + }); + var stderrTask = Task.Run(async () => + { + string? line; + while ((line = await process.StandardError.ReadLineAsync()) != null) + { + Console.Error.WriteLine($"[generator stderr] {line}"); + stderrLines.Add(line); + } + }); + + await process.WaitForExitAsync(); + await Task.WhenAll(stdoutTask, stderrTask); + + var exitCode = process.ExitCode; + Console.WriteLine($"Generator exited with code {exitCode}"); + + if (exitCode != 0) + { + return Results.Json( + new GenerateErrorResponse($"Generator failed with exit code {exitCode}", string.Join("\n", stderrLines.TakeLast(50))), + GenerateJsonContext.Default.GenerateErrorResponse, + statusCode: 500); + } + + // Collect all generated files + var files = new List(); + if (Directory.Exists(tempDir)) + { + foreach (var filePath in Directory.EnumerateFiles(tempDir, "*", SearchOption.AllDirectories)) + { + // Skip the input files + var fileName = Path.GetFileName(filePath); + if (fileName is "tspCodeModel.json" or "Configuration.json") + continue; + + var relativePath = Path.GetRelativePath(tempDir, filePath).Replace('\\', '/'); + var content = await File.ReadAllTextAsync(filePath); + files.Add(new GeneratedFile(relativePath, content)); + } + } + + return Results.Json( + new GenerateResponse(files), + GenerateJsonContext.Default.GenerateResponse); + } + finally + { + try { Directory.Delete(tempDir, recursive: true); } catch { } + } +}).RequireRateLimiting("generate"); + +var port = Environment.GetEnvironmentVariable("PORT") + ?? Environment.GetEnvironmentVariable("WEBSITES_PORT") + ?? "5174"; +var url = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? $"http://+:{port}"; +Console.WriteLine($"C# playground server listening on {url}"); +app.Run(url); + +// --- Request/Response types --- + +record GenerateRequest(string? CodeModel, string? Configuration, string? GeneratorName); +record GeneratedFile(string Path, string Content); +record GenerateResponse(List Files); +record GenerateErrorResponse(string Error, string? Details); + +[JsonSerializable(typeof(GenerateRequest))] +[JsonSerializable(typeof(GenerateResponse))] +[JsonSerializable(typeof(GenerateErrorResponse))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +partial class GenerateJsonContext : JsonSerializerContext { } diff --git a/packages/http-client-csharp/playground-server/playground-server.csproj b/packages/http-client-csharp/playground-server/playground-server.csproj new file mode 100644 index 00000000000..8c5ce456c81 --- /dev/null +++ b/packages/http-client-csharp/playground-server/playground-server.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + PlaygroundServer + + + diff --git a/website/src/components/playground-component/playground.tsx b/website/src/components/playground-component/playground.tsx index 2b19e2f401b..e69fce27198 100644 --- a/website/src/components/playground-component/playground.tsx +++ b/website/src/components/playground-component/playground.tsx @@ -16,6 +16,11 @@ import { LoadingSpinner } from "./loading-spinner"; import "@typespec/playground-website/style.css"; import "@typespec/playground/styles.css"; +// Configure the playground server URL for the C# emitter's browser stub. +// This must be set before the emitter runs. +(globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ = + "https://csharp-playground-server.azurewebsites.net"; + export interface WebsitePlaygroundProps { versionData: VersionData; } diff --git a/website/src/components/react-pages/playground.tsx b/website/src/components/react-pages/playground.tsx index 5ed019fabbb..326124bdf55 100644 --- a/website/src/components/react-pages/playground.tsx +++ b/website/src/components/react-pages/playground.tsx @@ -3,7 +3,10 @@ import { useEffect, useState, type ReactNode } from "react"; import { FluentLayout } from "../fluent/fluent-layout"; import { loadImportMap, type VersionData } from "../playground-component/import-map"; -const additionalPlaygroundPackages = ["@typespec/http-client-python"]; +const additionalPlaygroundPackages = [ + "@typespec/http-client-csharp", + "@typespec/http-client-python", +]; export const AsyncPlayground = ({ latestVersion, From 437b2ca810c338aa8baf5d3c9440d491e8800843 Mon Sep 17 00:00:00 2001 From: jolov Date: Sun, 12 Apr 2026 11:39:45 -0700 Subject: [PATCH 02/19] fix: preserve EsrpRelease in emitter-stages, add playground server deployment Only add new parameters (PlaygroundBundleBuildScript, PlaygroundServerDockerfile, PlaygroundServerAppName) and server deployment section. No changes to existing publish/ESRP pipeline logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/stages/emitter-stages.yml | 71 +++++++++---------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index d2b5800ac76..d33f87cc0f8 100644 --- a/eng/emitters/pipelines/templates/stages/emitter-stages.yml +++ b/eng/emitters/pipelines/templates/stages/emitter-stages.yml @@ -95,12 +95,12 @@ parameters: # Path to a Dockerfile (relative to PackagePath) for the playground server. # When set alongside UploadPlaygroundBundle, the server container is built and - # deployed to Azure Container Apps after publishing. + # deployed to Azure App Service after publishing. - name: PlaygroundServerDockerfile type: string default: "" - # Azure Container Apps name for the playground server. + # Azure App Service name for the playground server. - name: PlaygroundServerAppName type: string default: "" @@ -312,29 +312,37 @@ stages: npmrcPath: $(buildArtifactsPath)/packages/.npmrc registryUrl: https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ - - ${{ each package in parameters.Packages }}: - - ${{ if eq(package.type, 'npm') }}: - - pwsh: | - $file = Resolve-Path "${{ package.file }}" - Write-Host "npm publish $file --verbose --access public --prefix $(buildArtifactsPath)/packages" - npm publish $file --verbose --access public --prefix $(buildArtifactsPath)/packages - displayName: Publish ${{ package.name }} to internal feed - workingDirectory: $(buildArtifactsPath)/packages + # publish to devops feed + - pwsh: | + $packageFiles = Get-ChildItem -Path . -Filter '*.tgz' + foreach ($file in $packageFiles.Name) { + Write-Host "npm publish $file --verbose --access public" + npm publish $file --verbose --access public + } + displayName: Publish to DevOps feed + workingDirectory: $(buildArtifactsPath)/packages # If publishing publicly, also publish to npmjs.org - ${{ if eq(parameters.Publish, 'public') }}: - - pwsh: | - "//registry.npmjs.org/:_authToken=$(azure-sdk-npm-token)" | Out-File '.npmrc' - displayName: Authenticate .npmrc for npmjs.org - workingDirectory: $(buildArtifactsPath)/packages - - ${{ each package in parameters.Packages }}: - - ${{ if eq(package.type, 'npm') }}: - - pwsh: | - $file = Resolve-Path "${{ package.file }}" - Write-Host "npm publish $file --verbose --access public --prefix $(buildArtifactsPath)/packages" - npm publish $file --verbose --access public --prefix $(buildArtifactsPath)/packages - displayName: Publish ${{ package.name }} to npmjs.org - workingDirectory: $(buildArtifactsPath)/packages + # publish to npmjs.org using ESRP + - task: EsrpRelease@11 + inputs: + displayName: Publish to npmjs.org + ConnectedServiceName: Azure SDK PME Managed Identity + ClientId: 5f81938c-2544-4f1f-9251-dd9de5b8a81b + DomainTenantId: 975f013f-7f24-47e8-a7d3-abc4752bf346 + UseManagedIdentity: true + KeyVaultName: kv-azuresdk-codesign + SignCertName: azure-sdk-esrp-release-certificate + Intent: PackageDistribution + ContentType: npm + FolderLocation: $(buildArtifactsPath)/packages + Owners: ${{ coalesce(variables['Build.RequestedForEmail'], 'azuresdk@microsoft.com') }} + Approvers: ${{ coalesce(variables['Build.RequestedForEmail'], 'azuresdk@microsoft.com') }} + ServiceEndpointUrl: https://api.esrp.microsoft.com + # cspell:ignore ESRPRELPACMANTEST + MainPublisher: ESRPRELPACMANTEST + - ${{ if parameters.HasNugetPackages }}: - task: 1ES.PublishNuget@1 displayName: Publish Nuget packages @@ -357,27 +365,14 @@ stages: LanguageShortName: ${{ parameters.LanguageShortName }} - ${{ if parameters.UploadPlaygroundBundle }}: - - script: npm install -g pnpm - displayName: Install pnpm for playground bundle upload - - script: | - PUBLISHED_VERSION=$(ls $(buildArtifactsPath)/packages/*.tgz | head -1 | sed 's/.*-\([0-9].*\)\.tgz/\1/') - echo "Published version: $PUBLISHED_VERSION" - if [ -n "$PUBLISHED_VERSION" ]; then - node -e "const p=require('./package.json'); p.version='$PUBLISHED_VERSION'; require('fs').writeFileSync('./package.json', JSON.stringify(p, null, 2)+'\n')" - echo "Updated package.json version to $PUBLISHED_VERSION" - fi - displayName: Sync version from published package - workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - script: npm ci displayName: Install emitter dependencies for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - script: npm run ${{ parameters.PlaygroundBundleBuildScript }} displayName: Build emitter for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - - task: NodeTool@0 - displayName: Install Node.js for playground bundle - inputs: - versionSpec: "22.x" + - script: npm install -g pnpm + displayName: Install pnpm for playground bundle upload - script: pnpm install --filter "@typespec/bundle-uploader..." displayName: Install bundle-uploader dependencies workingDirectory: $(Build.SourcesDirectory) @@ -392,7 +387,7 @@ stages: inlineScript: node ./eng/emitters/scripts/upload-bundled-emitter.js ${{ parameters.PackagePath }} workingDirectory: $(Build.SourcesDirectory) - - ${{ if and(parameters.UploadPlaygroundBundle, ne(parameters.PlaygroundServerDockerfile, '')) }}: + - ${{ if and(parameters.PlaygroundServerDockerfile, parameters.PlaygroundServerAppName) }}: - task: AzureCLI@1 displayName: Build and deploy playground server inputs: From da56524af9cdbd087ee3ce400c8fc020c41e6376 Mon Sep 17 00:00:00 2001 From: jolov Date: Sun, 12 Apr 2026 11:42:08 -0700 Subject: [PATCH 03/19] fix: restore plugin path resolution in emitter.ts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/http-client-csharp/emitter/src/emitter.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/http-client-csharp/emitter/src/emitter.ts b/packages/http-client-csharp/emitter/src/emitter.ts index 8728b440f0a..1334b9b9ce4 100644 --- a/packages/http-client-csharp/emitter/src/emitter.ts +++ b/packages/http-client-csharp/emitter/src/emitter.ts @@ -1,13 +1,17 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. -import { createSdkContext, SdkContext } from "@azure-tools/typespec-client-generator-core"; +import { + createSdkContext, + SdkContext, +} from "@azure-tools/typespec-client-generator-core"; import { createDiagnosticCollector, Diagnostic, EmitContext, Program, } from "@typespec/compiler"; +import { resolve } from "path"; import { serializeCodeModel } from "./code-model-writer.js"; import { generate } from "./emit-generate.js"; import { createModel } from "./lib/client-model-builder.js"; @@ -51,6 +55,11 @@ export async function emitCodeModel( const options = resolveOptions(context); const outputFolder = context.emitterOutputDir; + // Resolve plugin paths to absolute if specified + if (options["plugins"]) { + options["plugins"] = options["plugins"].map((p) => resolve(outputFolder, p)); + } + /* set the log level. */ const logger = new Logger(program, options.logLevel ?? LoggerLevel.INFO); From b07b37019d5b145b224dee1e0e6df00defb3aba2 Mon Sep 17 00:00:00 2001 From: jolov Date: Sun, 12 Apr 2026 11:48:37 -0700 Subject: [PATCH 04/19] fix: prettier formatting, cspell words, lint cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cspell.yaml | 2 ++ .../emitter/src/emit-generate.browser.ts | 2 +- .../http-client-csharp/emitter/src/emit-generate.ts | 6 +----- packages/http-client-csharp/emitter/src/emitter.ts | 12 ++---------- .../emitter/test/Unit/emitter.test.ts | 8 ++------ 5 files changed, 8 insertions(+), 22 deletions(-) diff --git a/cspell.yaml b/cspell.yaml index 1ec94512ccd..9b5c8959da0 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -49,6 +49,7 @@ words: - CORGE - createsorreplacesresource - createsorupdatesresource + - Creds - CRUDL - ctxt - dbaeumer @@ -271,6 +272,7 @@ words: - tspwebsitepr - tsvs - typespec + - typespecacr - typespecvs - tzname - Uhoh diff --git a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts index a45f5a230c7..4fa0e012737 100644 --- a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts +++ b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts @@ -4,8 +4,8 @@ // Browser implementation: sends code model to a playground server via fetch. import { resolvePath } from "@typespec/compiler"; -import { CSharpEmitterContext } from "./sdk-context.js"; import type { GenerateOptions } from "./emit-generate.js"; +import { CSharpEmitterContext } from "./sdk-context.js"; export async function generate( sdkContext: CSharpEmitterContext, diff --git a/packages/http-client-csharp/emitter/src/emit-generate.ts b/packages/http-client-csharp/emitter/src/emit-generate.ts index 7e88c2d6e3e..41d14546b9b 100644 --- a/packages/http-client-csharp/emitter/src/emit-generate.ts +++ b/packages/http-client-csharp/emitter/src/emit-generate.ts @@ -83,11 +83,7 @@ export async function generate( configJson, ); - const csProjFile = resolvePath( - options.outputFolder, - "src", - `${options.packageName}.csproj`, - ); + const csProjFile = resolvePath(options.outputFolder, "src", `${options.packageName}.csproj`); const emitterPath = options.emitterExtensionPath ?? import.meta.url; const projectRoot = findProjectRoot(dirname(fileURLToPath(emitterPath))); diff --git a/packages/http-client-csharp/emitter/src/emitter.ts b/packages/http-client-csharp/emitter/src/emitter.ts index 1334b9b9ce4..a24e86119e7 100644 --- a/packages/http-client-csharp/emitter/src/emitter.ts +++ b/packages/http-client-csharp/emitter/src/emitter.ts @@ -1,16 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. -import { - createSdkContext, - SdkContext, -} from "@azure-tools/typespec-client-generator-core"; -import { - createDiagnosticCollector, - Diagnostic, - EmitContext, - Program, -} from "@typespec/compiler"; +import { createSdkContext, SdkContext } from "@azure-tools/typespec-client-generator-core"; +import { createDiagnosticCollector, Diagnostic, EmitContext, Program } from "@typespec/compiler"; import { resolve } from "path"; import { serializeCodeModel } from "./code-model-writer.js"; import { generate } from "./emit-generate.js"; diff --git a/packages/http-client-csharp/emitter/test/Unit/emitter.test.ts b/packages/http-client-csharp/emitter/test/Unit/emitter.test.ts index 2e0a4e7f754..c937a9ea0d4 100644 --- a/packages/http-client-csharp/emitter/test/Unit/emitter.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/emitter.test.ts @@ -1,16 +1,13 @@ vi.resetModules(); -import { Diagnostic, EmitContext, Program } from "@typespec/compiler"; +import { EmitContext, Program } from "@typespec/compiler"; import { TestHost } from "@typespec/compiler/testing"; -import { strictEqual } from "assert"; -import { statSync } from "fs"; -import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { generate } from "../../src/emit-generate.js"; import { execAsync, execCSharpGenerator } from "../../src/lib/utils.js"; import { CSharpEmitterOptions } from "../../src/options.js"; import { CodeModel } from "../../src/type/code-model.js"; import { - createCSharpSdkContext, createEmitterContext, createEmitterTestHost, getCreateSdkContext, @@ -246,4 +243,3 @@ describe("emitCodeModel tests", () => { expect(Array.isArray(diagnostics)).toBe(true); }); }); - From a6006ff8478ba3382379aceb27654acfb6f241f1 Mon Sep 17 00:00:00 2001 From: jolov Date: Sun, 12 Apr 2026 12:15:21 -0700 Subject: [PATCH 05/19] fix: clarify browser stub comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../http-client-csharp/emitter/src/emit-generate.browser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts index 4fa0e012737..f00d5211291 100644 --- a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts +++ b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. -// Browser implementation: sends code model to a playground server via fetch. +// Browser implementation: sends code model to a playground server for generation. import { resolvePath } from "@typespec/compiler"; import type { GenerateOptions } from "./emit-generate.js"; From f03059f83b0b5886513e6f0c0b436232481755d2 Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 09:44:22 -0700 Subject: [PATCH 06/19] fix: address PR review feedback - Simplify pipeline: remove Azure resource creation (ACR, plan, webapp, identity) since resources already exist manually; keep only ACR build and App Service container update - Move server URL default into browser stub so website doesn't need to set globalThis.__TYPESPEC_PLAYGROUND_SERVER_URL__ - Add --no-restore to Dockerfile publish step (restore already done) - Add **/artifacts/ to .dockerignore - Add azure.github.io to CORS allowed origins Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/stages/emitter-stages.yml | 52 +------------------ packages/http-client-csharp/.dockerignore | 1 + .../emitter/src/emit-generate.browser.ts | 12 ++--- .../playground-server/Dockerfile | 2 +- .../playground-server/Program.cs | 1 + .../playground-component/playground.tsx | 5 -- 6 files changed, 9 insertions(+), 64 deletions(-) diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index d33f87cc0f8..41f97b01272 100644 --- a/eng/emitters/pipelines/templates/stages/emitter-stages.yml +++ b/eng/emitters/pipelines/templates/stages/emitter-stages.yml @@ -400,13 +400,7 @@ stages: APP_NAME="${{ parameters.PlaygroundServerAppName }}" IMAGE="$REGISTRY.azurecr.io/$APP_NAME:$(Build.BuildId)" - # Create ACR if it doesn't exist - if ! az acr show --name "$REGISTRY" --resource-group "$RESOURCE_GROUP" &>/dev/null; then - echo "Creating container registry: $REGISTRY" - az acr create --name "$REGISTRY" --resource-group "$RESOURCE_GROUP" --sku Basic --admin-enabled false - fi - - # Build and push Docker image + # Build and push Docker image to ACR echo "Building Docker image: $IMAGE" CONTEXT="$(Build.SourcesDirectory)/${{ parameters.PackagePath }}" az acr build \ @@ -415,55 +409,13 @@ stages: --file "$CONTEXT/${{ parameters.PlaygroundServerDockerfile }}" \ "$CONTEXT" - # Deploy to Azure App Service - PLAN_NAME="typespec-playground-plan" - - # Create App Service plan if it doesn't exist - if ! az appservice plan show --name "$PLAN_NAME" --resource-group "$RESOURCE_GROUP" &>/dev/null; then - echo "Creating App Service plan: $PLAN_NAME" - az appservice plan create \ - --name "$PLAN_NAME" \ - --resource-group "$RESOURCE_GROUP" \ - --is-linux \ - --sku P0v3 - fi - - # Create or update Web App - if az webapp show --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" &>/dev/null; then - echo "Updating Web App: $APP_NAME" - else - echo "Creating Web App: $APP_NAME" - az webapp create \ - --name "$APP_NAME" \ - --resource-group "$RESOURCE_GROUP" \ - --plan "$PLAN_NAME" \ - --container-image-name "$IMAGE" \ - --container-registry-url "https://$REGISTRY.azurecr.io" - fi - - # Ensure managed identity and ACR pull access - az webapp identity assign --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" 2>/dev/null || true - PRINCIPAL_ID=$(az webapp identity show --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" --query principalId -o tsv) - ACR_ID=$(az acr show --name "$REGISTRY" --resource-group "$RESOURCE_GROUP" --query id -o tsv) - az role assignment create --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal --role AcrPull --scope "$ACR_ID" 2>/dev/null || true - az webapp config set --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" \ - --generic-configurations '{"acrUseManagedIdentityCreds": true}' - - # Update container image + # Update App Service container image az webapp config container set \ --name "$APP_NAME" \ --resource-group "$RESOURCE_GROUP" \ --container-image-name "$IMAGE" \ --container-registry-url "https://$REGISTRY.azurecr.io" - # Configure app settings - az webapp config appsettings set \ - --name "$APP_NAME" \ - --resource-group "$RESOURCE_GROUP" \ - --settings \ - WEBSITES_PORT=5174 \ - PLAYGROUND_URLS="https://typespec.io,https://tspwebsitepr.z22.web.core.windows.net,https://cadlplayground.z22.web.core.windows.net" - echo "Deployed to https://$APP_NAME.azurewebsites.net" workingDirectory: $(Build.SourcesDirectory) diff --git a/packages/http-client-csharp/.dockerignore b/packages/http-client-csharp/.dockerignore index 26ee17cadf1..e6f62b1d693 100644 --- a/packages/http-client-csharp/.dockerignore +++ b/packages/http-client-csharp/.dockerignore @@ -2,6 +2,7 @@ node_modules/ dist/ emitter/ .tspd/ +**/artifacts/ *.md *.tsp package-lock.json diff --git a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts index f00d5211291..2e4a947df16 100644 --- a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts +++ b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts @@ -7,21 +7,17 @@ import { resolvePath } from "@typespec/compiler"; import type { GenerateOptions } from "./emit-generate.js"; import { CSharpEmitterContext } from "./sdk-context.js"; +const DEFAULT_SERVER_URL = "https://csharp-playground-server.azurewebsites.net"; + export async function generate( sdkContext: CSharpEmitterContext, codeModelJson: string, configJson: string, options: GenerateOptions, ): Promise { - const serverUrl = (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__; + const serverUrl = + (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ ?? DEFAULT_SERVER_URL; - if (!serverUrl) { - throw new Error( - "C# code generation requires a playground server. " + - "No server URL is configured. Set globalThis.__TYPESPEC_PLAYGROUND_SERVER_URL__ " + - "to the URL of a running C# playground server.", - ); - } const response = await fetch(`${serverUrl}/generate`, { method: "POST", diff --git a/packages/http-client-csharp/playground-server/Dockerfile b/packages/http-client-csharp/playground-server/Dockerfile index 28e85056355..953ffae59d5 100644 --- a/packages/http-client-csharp/playground-server/Dockerfile +++ b/packages/http-client-csharp/playground-server/Dockerfile @@ -12,7 +12,7 @@ RUN dotnet build generator -c Release COPY playground-server/playground-server.csproj playground-server/ RUN dotnet restore playground-server/playground-server.csproj COPY playground-server/ playground-server/ -RUN dotnet publish playground-server -c Release -o /app +RUN dotnet publish playground-server -c Release -o /app --no-restore # Copy generator output RUN cp -r dist/generator /app/generator diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index 381c899a769..ff10c267669 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -16,6 +16,7 @@ "http://localhost:3000", "https://typespec.io", "https://www.typespec.io", + "https://azure.github.io", }; // Add additional origins from PLAYGROUND_URLS (comma-separated) or PLAYGROUND_URL (single) var playgroundUrls = Environment.GetEnvironmentVariable("PLAYGROUND_URLS") diff --git a/website/src/components/playground-component/playground.tsx b/website/src/components/playground-component/playground.tsx index e69fce27198..2b19e2f401b 100644 --- a/website/src/components/playground-component/playground.tsx +++ b/website/src/components/playground-component/playground.tsx @@ -16,11 +16,6 @@ import { LoadingSpinner } from "./loading-spinner"; import "@typespec/playground-website/style.css"; import "@typespec/playground/styles.css"; -// Configure the playground server URL for the C# emitter's browser stub. -// This must be set before the emitter runs. -(globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ = - "https://csharp-playground-server.azurewebsites.net"; - export interface WebsitePlaygroundProps { versionData: VersionData; } From ef6e3061ee58530e5fa48a85f7d58d9e16a9f444 Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 10:00:16 -0700 Subject: [PATCH 07/19] style: format emit-generate.browser.ts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../http-client-csharp/emitter/src/emit-generate.browser.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts index 2e4a947df16..b2dacec1175 100644 --- a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts +++ b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts @@ -15,9 +15,7 @@ export async function generate( configJson: string, options: GenerateOptions, ): Promise { - const serverUrl = - (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ ?? DEFAULT_SERVER_URL; - + const serverUrl = (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ ?? DEFAULT_SERVER_URL; const response = await fetch(`${serverUrl}/generate`, { method: "POST", From f03f775daa7692d4cb13a03d14af0069190b4ba7 Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 10:28:25 -0700 Subject: [PATCH 08/19] test: add tests for browser stub (emit-generate.browser) - Test default and custom server URL - Test request body contains codeModel, configuration, generatorName - Test response files are written to host - Test error handling on non-OK response - Test empty files array Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/Unit/emit-generate-browser.test.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts diff --git a/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts new file mode 100644 index 00000000000..b8aec4ef352 --- /dev/null +++ b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts @@ -0,0 +1,155 @@ +vi.resetModules(); + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GenerateOptions } from "../../src/emit-generate.js"; +import type { CSharpEmitterContext } from "../../src/sdk-context.js"; + +// Mock @typespec/compiler to provide resolvePath +vi.mock("@typespec/compiler", () => ({ + resolvePath: (...segments: string[]) => segments.join("/"), +})); + +// Create a mock context with a writable host +function createMockContext(): CSharpEmitterContext { + return { + program: { + host: { + writeFile: vi.fn(), + }, + }, + } as unknown as CSharpEmitterContext; +} + +const defaultOptions: GenerateOptions = { + outputFolder: "/output", + generatorName: "ScmCodeModelGenerator", + newProject: false, +}; + +describe("emit-generate.browser", () => { + let generate: typeof import("../../src/emit-generate.browser.js").generate; + + beforeEach(async () => { + vi.resetModules(); + // Clear any custom server URL + delete (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__; + // Re-import to pick up fresh module state + generate = (await import("../../src/emit-generate.browser.js")).generate; + }); + + it("should POST code model to the default server URL", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ files: [] }), + }; + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const ctx = createMockContext(); + await generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions); + + expect(fetch).toHaveBeenCalledWith( + "https://csharp-playground-server.azurewebsites.net/generate", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + it("should use custom server URL from globalThis when set", async () => { + (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ = "https://custom-server.example.com"; + // Re-import to pick up the new global + generate = (await import("../../src/emit-generate.browser.js")).generate; + + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ files: [] }), + }; + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const ctx = createMockContext(); + await generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions); + + expect(fetch).toHaveBeenCalledWith( + "https://custom-server.example.com/generate", + expect.anything(), + ); + }); + + it("should send codeModel, configuration, and generatorName in request body", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ files: [] }), + }; + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const ctx = createMockContext(); + await generate(ctx, '{"model":"data"}', '{"namespace":"Test"}', { + ...defaultOptions, + generatorName: "CustomGenerator", + }); + + const callArgs = vi.mocked(fetch).mock.calls[0]; + const body = JSON.parse(callArgs[1]!.body as string); + expect(body).toEqual({ + codeModel: '{"model":"data"}', + configuration: '{"namespace":"Test"}', + generatorName: "CustomGenerator", + }); + }); + + it("should write response files to the host", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + files: [ + { path: "src/Generated/Model.cs", content: "public class Model {}" }, + { path: "src/Generated/Client.cs", content: "public class Client {}" }, + ], + }), + }; + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const ctx = createMockContext(); + await generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions); + + const writeFile = vi.mocked(ctx.program.host.writeFile); + expect(writeFile).toHaveBeenCalledTimes(2); + expect(writeFile).toHaveBeenCalledWith( + "/output/src/Generated/Model.cs", + "public class Model {}", + ); + expect(writeFile).toHaveBeenCalledWith( + "/output/src/Generated/Client.cs", + "public class Client {}", + ); + }); + + it("should throw on non-OK response", async () => { + const mockResponse = { + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('{"error":"Generator failed"}'), + }; + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const ctx = createMockContext(); + await expect( + generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions), + ).rejects.toThrow("Playground server error (500)"); + }); + + it("should handle empty files array in response", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ files: [] }), + }; + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const ctx = createMockContext(); + await generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions); + + const writeFile = vi.mocked(ctx.program.host.writeFile); + expect(writeFile).not.toHaveBeenCalled(); + }); +}); From a7c273bbc6d0c2d35504c1542c8d165aac8a04de Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 10:34:12 -0700 Subject: [PATCH 09/19] fix: add missing GenerateOptions properties in browser test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../emitter/test/Unit/emit-generate-browser.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts index b8aec4ef352..44b600c8853 100644 --- a/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts @@ -23,7 +23,10 @@ function createMockContext(): CSharpEmitterContext { const defaultOptions: GenerateOptions = { outputFolder: "/output", generatorName: "ScmCodeModelGenerator", + packageName: "TestPackage", newProject: false, + debug: false, + saveInputs: false, }; describe("emit-generate.browser", () => { From 99e0ee5e9c0ffdf08dfc1cc8e88bcbee9bf3903d Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 15:41:00 -0700 Subject: [PATCH 10/19] fix: require explicit server URL configuration Remove default server URL from browser stub to prevent it from leaking to third-party sites. The URL must be explicitly set via globalThis.__TYPESPEC_PLAYGROUND_SERVER_URL__ by the host site. Moved the URL configuration back to the website playground. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../emitter/src/emit-generate.browser.ts | 11 +++++-- .../test/Unit/emit-generate-browser.test.ts | 30 ++++++------------- .../playground-component/playground.tsx | 4 +++ 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts index b2dacec1175..5b916075461 100644 --- a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts +++ b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts @@ -7,15 +7,20 @@ import { resolvePath } from "@typespec/compiler"; import type { GenerateOptions } from "./emit-generate.js"; import { CSharpEmitterContext } from "./sdk-context.js"; -const DEFAULT_SERVER_URL = "https://csharp-playground-server.azurewebsites.net"; - export async function generate( sdkContext: CSharpEmitterContext, codeModelJson: string, configJson: string, options: GenerateOptions, ): Promise { - const serverUrl = (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ ?? DEFAULT_SERVER_URL; + const serverUrl = (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__; + + if (!serverUrl) { + throw new Error( + "C# code generation requires a playground server. " + + "Set globalThis.__TYPESPEC_PLAYGROUND_SERVER_URL__ to the server URL.", + ); + } const response = await fetch(`${serverUrl}/generate`, { method: "POST", diff --git a/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts index 44b600c8853..c9d11d3953f 100644 --- a/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts @@ -31,38 +31,26 @@ const defaultOptions: GenerateOptions = { describe("emit-generate.browser", () => { let generate: typeof import("../../src/emit-generate.browser.js").generate; + const TEST_SERVER_URL = "https://test-server.example.com"; beforeEach(async () => { vi.resetModules(); - // Clear any custom server URL - delete (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__; + // Set a test server URL by default + (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ = TEST_SERVER_URL; // Re-import to pick up fresh module state generate = (await import("../../src/emit-generate.browser.js")).generate; }); - it("should POST code model to the default server URL", async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ files: [] }), - }; - global.fetch = vi.fn().mockResolvedValue(mockResponse); - + it("should throw when no server URL is configured", async () => { + delete (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__; const ctx = createMockContext(); - await generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions); - - expect(fetch).toHaveBeenCalledWith( - "https://csharp-playground-server.azurewebsites.net/generate", - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); + await expect( + generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions), + ).rejects.toThrow("C# code generation requires a playground server"); }); - it("should use custom server URL from globalThis when set", async () => { + it("should use server URL from globalThis when set", async () => { (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ = "https://custom-server.example.com"; - // Re-import to pick up the new global - generate = (await import("../../src/emit-generate.browser.js")).generate; const mockResponse = { ok: true, diff --git a/website/src/components/playground-component/playground.tsx b/website/src/components/playground-component/playground.tsx index 2b19e2f401b..e55f344d448 100644 --- a/website/src/components/playground-component/playground.tsx +++ b/website/src/components/playground-component/playground.tsx @@ -16,6 +16,10 @@ import { LoadingSpinner } from "./loading-spinner"; import "@typespec/playground-website/style.css"; import "@typespec/playground/styles.css"; +// Configure the C# emitter playground server URL. +(globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ = + "https://csharp-playground-server.azurewebsites.net"; + export interface WebsitePlaygroundProps { versionData: VersionData; } From fadf720c9a3ac44dcde5546e095b7b481564c58a Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 15:45:21 -0700 Subject: [PATCH 11/19] fix: hardcode server URL in emitter, remove configurability The server URL is hardcoded in the browser stub with no globalThis fallback. No configuration needed from the website. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../emitter/src/emit-generate.browser.ts | 13 +++-------- .../test/Unit/emit-generate-browser.test.ts | 23 +++++-------------- .../playground-component/playground.tsx | 4 ---- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts index 5b916075461..fdf90a0613a 100644 --- a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts +++ b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts @@ -7,22 +7,15 @@ import { resolvePath } from "@typespec/compiler"; import type { GenerateOptions } from "./emit-generate.js"; import { CSharpEmitterContext } from "./sdk-context.js"; +const SERVER_URL = "https://csharp-playground-server.azurewebsites.net"; + export async function generate( sdkContext: CSharpEmitterContext, codeModelJson: string, configJson: string, options: GenerateOptions, ): Promise { - const serverUrl = (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__; - - if (!serverUrl) { - throw new Error( - "C# code generation requires a playground server. " + - "Set globalThis.__TYPESPEC_PLAYGROUND_SERVER_URL__ to the server URL.", - ); - } - - const response = await fetch(`${serverUrl}/generate`, { + const response = await fetch(`${SERVER_URL}/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts index c9d11d3953f..f6b2a523725 100644 --- a/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts @@ -31,27 +31,13 @@ const defaultOptions: GenerateOptions = { describe("emit-generate.browser", () => { let generate: typeof import("../../src/emit-generate.browser.js").generate; - const TEST_SERVER_URL = "https://test-server.example.com"; beforeEach(async () => { vi.resetModules(); - // Set a test server URL by default - (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ = TEST_SERVER_URL; - // Re-import to pick up fresh module state generate = (await import("../../src/emit-generate.browser.js")).generate; }); - it("should throw when no server URL is configured", async () => { - delete (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__; - const ctx = createMockContext(); - await expect( - generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions), - ).rejects.toThrow("C# code generation requires a playground server"); - }); - - it("should use server URL from globalThis when set", async () => { - (globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ = "https://custom-server.example.com"; - + it("should POST to the server URL", async () => { const mockResponse = { ok: true, json: vi.fn().mockResolvedValue({ files: [] }), @@ -62,8 +48,11 @@ describe("emit-generate.browser", () => { await generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions); expect(fetch).toHaveBeenCalledWith( - "https://custom-server.example.com/generate", - expect.anything(), + "https://csharp-playground-server.azurewebsites.net/generate", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), ); }); diff --git a/website/src/components/playground-component/playground.tsx b/website/src/components/playground-component/playground.tsx index e55f344d448..2b19e2f401b 100644 --- a/website/src/components/playground-component/playground.tsx +++ b/website/src/components/playground-component/playground.tsx @@ -16,10 +16,6 @@ import { LoadingSpinner } from "./loading-spinner"; import "@typespec/playground-website/style.css"; import "@typespec/playground/styles.css"; -// Configure the C# emitter playground server URL. -(globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ = - "https://csharp-playground-server.azurewebsites.net"; - export interface WebsitePlaygroundProps { versionData: VersionData; } From 664aff55e2331c0bc12b8a963a86563c69923953 Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 15:52:21 -0700 Subject: [PATCH 12/19] fix: use Node 22 for playground bundle upload in publish stage The publish stage didn't set a Node version, defaulting to Node 20 on the agent. The bundle-uploader depends on asset-emitter which requires Node >=22. Added NodeTool@0 step before the bundle upload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/emitters/pipelines/templates/stages/emitter-stages.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index 41f97b01272..cbbc2927c4e 100644 --- a/eng/emitters/pipelines/templates/stages/emitter-stages.yml +++ b/eng/emitters/pipelines/templates/stages/emitter-stages.yml @@ -365,6 +365,10 @@ stages: LanguageShortName: ${{ parameters.LanguageShortName }} - ${{ if parameters.UploadPlaygroundBundle }}: + - task: NodeTool@0 + displayName: Use Node 22.x for playground bundle + inputs: + versionSpec: "22.x" - script: npm ci displayName: Install emitter dependencies for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} From 5780707c1e74febba6ccdf54234326fb63746877 Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 17:12:42 -0700 Subject: [PATCH 13/19] fix: stamp build version on package.json before playground bundle upload The publish stage checks out fresh source with the original package.json version (e.g. 0.1.0). The bundle uploader reads this version for latest.json. Added a step to apply the build number (e.g. 0.1.0-beta.12345) before bundling, matching Build-Packages.ps1 behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pipelines/templates/stages/emitter-stages.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index cbbc2927c4e..fd81e7c1fb9 100644 --- a/eng/emitters/pipelines/templates/stages/emitter-stages.yml +++ b/eng/emitters/pipelines/templates/stages/emitter-stages.yml @@ -372,6 +372,16 @@ stages: - script: npm ci displayName: Install emitter dependencies for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} + - script: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + VERSION_TAG="${VERSION_TAG_VALUE}" + NEW_VERSION="${CURRENT_VERSION}-${VERSION_TAG}.$(Build.BuildNumber)" + echo "Setting version to $NEW_VERSION" + npm version "$NEW_VERSION" --no-git-tag-version + displayName: Stamp build version for playground bundle + workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} + env: + VERSION_TAG_VALUE: ${{ if parameters.BuildPrereleaseVersion }}alpha${{ else }}beta${{ end }} - script: npm run ${{ parameters.PlaygroundBundleBuildScript }} displayName: Build emitter for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} From ed629d07a39f6dabc6a36377b8ae454ed2bfa298 Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 17:26:33 -0700 Subject: [PATCH 14/19] fix: use BUILD_BUILDNUMBER env var in version stamp script ADO template parser was failing on the inline script. Use the auto-set BUILD_BUILDNUMBER environment variable instead of the macro syntax. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/emitters/pipelines/templates/stages/emitter-stages.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index fd81e7c1fb9..6cabd56d0ce 100644 --- a/eng/emitters/pipelines/templates/stages/emitter-stages.yml +++ b/eng/emitters/pipelines/templates/stages/emitter-stages.yml @@ -374,14 +374,11 @@ stages: workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - script: | CURRENT_VERSION=$(node -p "require('./package.json').version") - VERSION_TAG="${VERSION_TAG_VALUE}" - NEW_VERSION="${CURRENT_VERSION}-${VERSION_TAG}.$(Build.BuildNumber)" + NEW_VERSION="${CURRENT_VERSION}-beta.${BUILD_BUILDNUMBER}" echo "Setting version to $NEW_VERSION" npm version "$NEW_VERSION" --no-git-tag-version displayName: Stamp build version for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - env: - VERSION_TAG_VALUE: ${{ if parameters.BuildPrereleaseVersion }}alpha${{ else }}beta${{ end }} - script: npm run ${{ parameters.PlaygroundBundleBuildScript }} displayName: Build emitter for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} From b4d53db4d7557857769a92671ca4556d8fc81a6a Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 17:37:54 -0700 Subject: [PATCH 15/19] fix: use alpha/beta version tag based on BuildPrereleaseVersion C# emitter sets BuildPrereleaseVersion=true so gets -alpha tag, matching Build-Packages.ps1 behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/stages/emitter-stages.yml | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index 6cabd56d0ce..cb2c5182b61 100644 --- a/eng/emitters/pipelines/templates/stages/emitter-stages.yml +++ b/eng/emitters/pipelines/templates/stages/emitter-stages.yml @@ -372,13 +372,22 @@ stages: - script: npm ci displayName: Install emitter dependencies for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - - script: | - CURRENT_VERSION=$(node -p "require('./package.json').version") - NEW_VERSION="${CURRENT_VERSION}-beta.${BUILD_BUILDNUMBER}" - echo "Setting version to $NEW_VERSION" - npm version "$NEW_VERSION" --no-git-tag-version - displayName: Stamp build version for playground bundle - workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} + - ${{ if parameters.BuildPrereleaseVersion }}: + - script: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + NEW_VERSION="${CURRENT_VERSION}-alpha.${BUILD_BUILDNUMBER}" + echo "Setting version to $NEW_VERSION" + npm version "$NEW_VERSION" --no-git-tag-version + displayName: Stamp build version for playground bundle + workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} + - ${{ else }}: + - script: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + NEW_VERSION="${CURRENT_VERSION}-beta.${BUILD_BUILDNUMBER}" + echo "Setting version to $NEW_VERSION" + npm version "$NEW_VERSION" --no-git-tag-version + displayName: Stamp build version for playground bundle + workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - script: npm run ${{ parameters.PlaygroundBundleBuildScript }} displayName: Build emitter for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} From 700d1b1e646837451429ea4f92169744d3d62e06 Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 13 Apr 2026 17:47:18 -0700 Subject: [PATCH 16/19] style: format emitter-stages.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/stages/emitter-stages.yml | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index cb2c5182b61..b5e727c31ba 100644 --- a/eng/emitters/pipelines/templates/stages/emitter-stages.yml +++ b/eng/emitters/pipelines/templates/stages/emitter-stages.yml @@ -373,21 +373,21 @@ stages: displayName: Install emitter dependencies for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - ${{ if parameters.BuildPrereleaseVersion }}: - - script: | - CURRENT_VERSION=$(node -p "require('./package.json').version") - NEW_VERSION="${CURRENT_VERSION}-alpha.${BUILD_BUILDNUMBER}" - echo "Setting version to $NEW_VERSION" - npm version "$NEW_VERSION" --no-git-tag-version - displayName: Stamp build version for playground bundle - workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} + - script: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + NEW_VERSION="${CURRENT_VERSION}-alpha.${BUILD_BUILDNUMBER}" + echo "Setting version to $NEW_VERSION" + npm version "$NEW_VERSION" --no-git-tag-version + displayName: Stamp build version for playground bundle + workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - ${{ else }}: - - script: | - CURRENT_VERSION=$(node -p "require('./package.json').version") - NEW_VERSION="${CURRENT_VERSION}-beta.${BUILD_BUILDNUMBER}" - echo "Setting version to $NEW_VERSION" - npm version "$NEW_VERSION" --no-git-tag-version - displayName: Stamp build version for playground bundle - workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} + - script: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + NEW_VERSION="${CURRENT_VERSION}-beta.${BUILD_BUILDNUMBER}" + echo "Setting version to $NEW_VERSION" + npm version "$NEW_VERSION" --no-git-tag-version + displayName: Stamp build version for playground bundle + workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} - script: npm run ${{ parameters.PlaygroundBundleBuildScript }} displayName: Build emitter for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} From 5cb4659466e26cd1b2ae1deed7b0e46bc6e8ebde Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 14 Apr 2026 19:37:27 -0700 Subject: [PATCH 17/19] feat: add request/response validation and security hardening Server-side (Program.cs): - Request size limit: 10 MB max body via Kestrel - Content-Type validation: must be application/json - JSON parse error handling: returns 400 on invalid JSON - Subprocess timeout: 5 minutes, kills process tree on timeout (504) - Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy Client-side (emit-generate.browser.ts): - Validate response content-type is application/json - Validate response shape: { files: Array<{ path, content }> } - Validate each file entry has string path and content Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../emitter/src/emit-generate.browser.ts | 14 ++++- .../playground-server/Program.cs | 54 +++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts index fdf90a0613a..713c8fa7b03 100644 --- a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts +++ b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts @@ -30,9 +30,21 @@ export async function generate( throw new Error(`Playground server error (${response.status}): ${errorText}`); } - const result: { files: Array<{ path: string; content: string }> } = await response.json(); + const contentType = response.headers.get("content-type"); + if (!contentType?.includes("application/json")) { + throw new Error(`Unexpected response content-type: ${contentType}`); + } + + const result = await response.json(); + + if (!result || !Array.isArray(result.files)) { + throw new Error("Invalid response: expected { files: [...] }"); + } for (const file of result.files) { + if (typeof file.path !== "string" || typeof file.content !== "string") { + throw new Error(`Invalid file entry: expected { path: string, content: string }`); + } await sdkContext.program.host.writeFile( resolvePath(options.outputFolder, file.path), file.content, diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index ff10c267669..ea0b84df78c 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -7,6 +7,9 @@ using System.Threading.RateLimiting; using Microsoft.AspNetCore.RateLimiting; +const int MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB +const int GeneratorTimeoutSeconds = 300; + var builder = WebApplication.CreateBuilder(args); var allowedOrigins = new HashSet(StringComparer.OrdinalIgnoreCase) @@ -45,8 +48,23 @@ }); }); +// Limit request body size +builder.WebHost.ConfigureKestrel(options => +{ + options.Limits.MaxRequestBodySize = MaxRequestBodySize; +}); + var app = builder.Build(); +// Security headers +app.Use(async (context, next) => +{ + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + await next(); +}); + app.UseCors(policy => policy .WithOrigins([.. allowedOrigins]) .AllowAnyMethod() @@ -94,14 +112,30 @@ app.MapPost("/generate", async (HttpRequest request) => { - var body = await JsonSerializer.DeserializeAsync( - request.Body, GenerateJsonContext.Default.GenerateRequest); + // Validate content type + if (!request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) ?? true) + { + return Results.BadRequest(new { error = "Content-Type must be application/json" }); + } + + GenerateRequest? body; + try + { + body = await JsonSerializer.DeserializeAsync( + request.Body, GenerateJsonContext.Default.GenerateRequest); + } + catch (JsonException) + { + return Results.BadRequest(new { error = "Invalid JSON in request body" }); + } if (body?.CodeModel is null || body?.Configuration is null) { return Results.BadRequest(new { error = "Missing 'codeModel' or 'configuration' fields" }); } + var generatorName = body.GeneratorName ?? "ScmCodeModelGenerator"; + if (!File.Exists(generatorPath)) { return Results.StatusCode(503); @@ -118,8 +152,6 @@ await File.WriteAllTextAsync(Path.Combine(tempDir, "tspCodeModel.json"), body.CodeModel); await File.WriteAllTextAsync(Path.Combine(tempDir, "Configuration.json"), body.Configuration); - var generatorName = body.GeneratorName ?? "ScmCodeModelGenerator"; - // Run the .NET generator as a subprocess Console.WriteLine($"Starting generator: dotnet --roll-forward Major {generatorPath} {tempDir} -g {generatorName} --new-project"); Console.WriteLine($"Code model size: {body.CodeModel!.Length} chars"); @@ -136,6 +168,7 @@ }; using var process = Process.Start(psi)!; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(GeneratorTimeoutSeconds)); // Stream stdout/stderr to console for logging var stderrLines = new List(); @@ -157,7 +190,18 @@ } }); - await process.WaitForExitAsync(); + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + return Results.Json( + new GenerateErrorResponse("Generator timed out", $"Process did not complete within {GeneratorTimeoutSeconds} seconds"), + GenerateJsonContext.Default.GenerateErrorResponse, + statusCode: 504); + } await Task.WhenAll(stdoutTask, stderrTask); var exitCode = process.ExitCode; From bd34a3c27b3a664407587a2a495ae2490d6d987b Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 14 Apr 2026 20:44:28 -0700 Subject: [PATCH 18/19] fix: add headers to mock responses in browser stub tests Mock responses now include Headers with content-type to match the response validation added in the browser stub. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/Unit/emit-generate-browser.test.ts | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts index f6b2a523725..9d47f0c06b8 100644 --- a/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/emit-generate-browser.test.ts @@ -29,6 +29,16 @@ const defaultOptions: GenerateOptions = { saveInputs: false, }; +function createMockResponse(body: any, opts?: { ok?: boolean; status?: number }) { + return { + ok: opts?.ok ?? true, + status: opts?.status ?? 200, + headers: new Headers({ "content-type": "application/json" }), + json: vi.fn().mockResolvedValue(body), + text: vi.fn().mockResolvedValue(JSON.stringify(body)), + }; +} + describe("emit-generate.browser", () => { let generate: typeof import("../../src/emit-generate.browser.js").generate; @@ -38,11 +48,7 @@ describe("emit-generate.browser", () => { }); it("should POST to the server URL", async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ files: [] }), - }; - global.fetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = vi.fn().mockResolvedValue(createMockResponse({ files: [] })); const ctx = createMockContext(); await generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions); @@ -57,11 +63,7 @@ describe("emit-generate.browser", () => { }); it("should send codeModel, configuration, and generatorName in request body", async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ files: [] }), - }; - global.fetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = vi.fn().mockResolvedValue(createMockResponse({ files: [] })); const ctx = createMockContext(); await generate(ctx, '{"model":"data"}', '{"namespace":"Test"}', { @@ -79,16 +81,14 @@ describe("emit-generate.browser", () => { }); it("should write response files to the host", async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ + global.fetch = vi.fn().mockResolvedValue( + createMockResponse({ files: [ { path: "src/Generated/Model.cs", content: "public class Model {}" }, { path: "src/Generated/Client.cs", content: "public class Client {}" }, ], }), - }; - global.fetch = vi.fn().mockResolvedValue(mockResponse); + ); const ctx = createMockContext(); await generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions); @@ -106,12 +106,11 @@ describe("emit-generate.browser", () => { }); it("should throw on non-OK response", async () => { - const mockResponse = { - ok: false, - status: 500, - text: vi.fn().mockResolvedValue('{"error":"Generator failed"}'), - }; - global.fetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = vi + .fn() + .mockResolvedValue( + createMockResponse({ error: "Generator failed" }, { ok: false, status: 500 }), + ); const ctx = createMockContext(); await expect( @@ -120,11 +119,7 @@ describe("emit-generate.browser", () => { }); it("should handle empty files array in response", async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ files: [] }), - }; - global.fetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = vi.fn().mockResolvedValue(createMockResponse({ files: [] })); const ctx = createMockContext(); await generate(ctx, '{"model":"test"}', '{"config":"test"}', defaultOptions); From cf88a0a7f4ca3c83539de31cf55ff6a5ed49afc5 Mon Sep 17 00:00:00 2001 From: jolov Date: Fri, 17 Apr 2026 08:15:08 -0700 Subject: [PATCH 19/19] feat: add response size limit check in browser emitter Checks Content-Length header against a 10 MB limit before parsing the response JSON to protect against unexpectedly large responses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../emitter/src/emit-generate.browser.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts index 713c8fa7b03..cb557a2ba6d 100644 --- a/packages/http-client-csharp/emitter/src/emit-generate.browser.ts +++ b/packages/http-client-csharp/emitter/src/emit-generate.browser.ts @@ -8,6 +8,7 @@ import type { GenerateOptions } from "./emit-generate.js"; import { CSharpEmitterContext } from "./sdk-context.js"; const SERVER_URL = "https://csharp-playground-server.azurewebsites.net"; +const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10 MB export async function generate( sdkContext: CSharpEmitterContext, @@ -35,6 +36,13 @@ export async function generate( throw new Error(`Unexpected response content-type: ${contentType}`); } + const contentLength = response.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) { + throw new Error( + `Response too large: ${contentLength} bytes exceeds ${MAX_RESPONSE_SIZE} byte limit`, + ); + } + const result = await response.json(); if (!result || !Array.isArray(result.files)) {