diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index 0ef45d4ec1e..803ebda7291 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 P1v3 + 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/bundle-uploader/src/upload-browser-package.ts b/packages/bundle-uploader/src/upload-browser-package.ts index c2815847b9f..7ecef6d34fd 100644 --- a/packages/bundle-uploader/src/upload-browser-package.ts +++ b/packages/bundle-uploader/src/upload-browser-package.ts @@ -120,6 +120,7 @@ export class TypeSpecBundledPackageUploader { await blob.upload(content, content.length, { blobHTTPHeaders: { blobContentType: "application/json; charset=utf-8", + blobCacheControl: "no-cache, max-age=0", }, }); } @@ -145,6 +146,7 @@ export class TypeSpecBundledPackageUploader { await blob.upload(content, content.length, { blobHTTPHeaders: { blobContentType: "application/json; charset=utf-8", + blobCacheControl: "no-cache, max-age=0", }, }); } 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/src/CSharpGen.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs index 504f9c31e15..604c417fd7a 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs @@ -28,15 +28,28 @@ public async Task ExecuteAsync() CodeModelGenerator.Instance.Emitter.Info("Starting code generation"); CodeModelGenerator.Instance.Stopwatch.Start(); + // Pre-warm JIT compilation of ParameterProvider interface methods + // to avoid potential dispatch issues in sandboxed container environments. + System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod( + typeof(Providers.ParameterProvider).GetMethod("Equals", new[] { typeof(Providers.ParameterProvider) })!.MethodHandle); + System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod( + typeof(Providers.ParameterProvider).GetMethod("GetHashCode", Type.EmptyTypes)!.MethodHandle); + + CodeModelGenerator.Instance.Emitter.Info("[diag] Before GeneratedCodeWorkspace.Initialize()"); GeneratedCodeWorkspace.Initialize(); + CodeModelGenerator.Instance.Emitter.Info("[diag] After GeneratedCodeWorkspace.Initialize()"); var outputPath = CodeModelGenerator.Instance.Configuration.OutputDirectory; var generatedSourceOutputPath = CodeModelGenerator.Instance.Configuration.ProjectGeneratedDirectory; // Resolve PackageReference items from the .csproj so custom code referencing // external NuGet types (e.g., Azure.Storage.Common) compiles correctly. + CodeModelGenerator.Instance.Emitter.Info("[diag] Before AddPackageReferencesFromProject"); await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + CodeModelGenerator.Instance.Emitter.Info("[diag] After AddPackageReferencesFromProject"); + CodeModelGenerator.Instance.Emitter.Info("[diag] Before Create(customCode)"); GeneratedCodeWorkspace customCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: true); + CodeModelGenerator.Instance.Emitter.Info("[diag] After Create(customCode)"); // The generated attributes need to be added into the workspace before loading the custom code. Otherwise, // Roslyn doesn't load the attributes completely and we are unable to get the attribute arguments. @@ -47,17 +60,24 @@ public async Task ExecuteAsync() } await Task.WhenAll(generateAttributeTasks); + CodeModelGenerator.Instance.Emitter.Info("[diag] After AddInMemoryFile attributes"); - CodeModelGenerator.Instance.SourceInputModel = new SourceInputModel( - await customCodeWorkspace.GetCompilationAsync(), - await GeneratedCodeWorkspace.LoadBaselineContract()); + CodeModelGenerator.Instance.Emitter.Info("[diag] Before customCodeWorkspace.GetCompilationAsync()"); + var customCompilation = await customCodeWorkspace.GetCompilationAsync(); + CodeModelGenerator.Instance.Emitter.Info("[diag] After customCodeWorkspace.GetCompilationAsync()"); + CodeModelGenerator.Instance.Emitter.Info("[diag] Before LoadBaselineContract()"); + var baselineContract = await GeneratedCodeWorkspace.LoadBaselineContract(); + CodeModelGenerator.Instance.Emitter.Info("[diag] After LoadBaselineContract()"); + + CodeModelGenerator.Instance.SourceInputModel = new SourceInputModel(customCompilation, baselineContract); + + CodeModelGenerator.Instance.Emitter.Info("[diag] Before Create(generatedCode)"); GeneratedCodeWorkspace generatedCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + CodeModelGenerator.Instance.Emitter.Info("[diag] After Create(generatedCode)"); var output = CodeModelGenerator.Instance.OutputLibrary; Directory.CreateDirectory(Path.Combine(generatedSourceOutputPath, "Models")); - List generateFilesTasks = new(); - // Build all TypeProviders foreach (var type in output.TypeProviders) { @@ -76,23 +96,43 @@ await customCodeWorkspace.GetCompilationAsync(), LoggingHelpers.LogElapsedTime("All visitors have been applied"); + int fileCount = 0; foreach (var outputType in output.TypeProviders) { + Console.Error.WriteLine($"[diag] Processing type {fileCount}: {outputType.Name} (type: {outputType.GetType().Name})"); + Console.Error.Flush(); + // Ensure back-compatibility processing is done after all visitors have run outputType.ProcessTypeForBackCompatibility(); var writer = CodeModelGenerator.Instance.GetWriter(outputType); - generateFilesTasks.Add(generatedCodeWorkspace.AddGeneratedFile(writer.Write())); + Console.Error.WriteLine($"[diag] Writer created for {outputType.Name} (methods: {outputType.Methods.Count}, props: {outputType.Properties.Count}), calling Write()"); + Console.Error.Flush(); + var codeFile = writer.Write(); + Console.Error.WriteLine($"[diag] Adding file {fileCount}: {codeFile.Name} ({codeFile.Content.Length} chars)"); + Console.Error.Flush(); + await generatedCodeWorkspace.AddGeneratedFile(codeFile); + Console.Error.WriteLine($"[diag] Added file {fileCount}: {codeFile.Name}"); + Console.Error.Flush(); + fileCount++; foreach (var serialization in outputType.SerializationProviders) { + Console.Error.WriteLine($"[diag] Processing serialization for {outputType.Name}: {serialization.Name}"); + Console.Error.Flush(); writer = CodeModelGenerator.Instance.GetWriter(serialization); - generateFilesTasks.Add(generatedCodeWorkspace.AddGeneratedFile(writer.Write())); + codeFile = writer.Write(); + Console.Error.WriteLine($"[diag] Adding file {fileCount}: {codeFile.Name} ({codeFile.Content.Length} chars)"); + Console.Error.Flush(); + await generatedCodeWorkspace.AddGeneratedFile(codeFile); + Console.Error.WriteLine($"[diag] Added file {fileCount}: {codeFile.Name}"); + Console.Error.Flush(); + fileCount++; } } - // Add all the generated files to the workspace - await Task.WhenAll(generateFilesTasks); + Console.Error.WriteLine($"[diag] All {fileCount} files added to workspace"); + Console.Error.Flush(); LoggingHelpers.LogElapsedTime("All generated types have been written into memory"); @@ -101,9 +141,12 @@ await customCodeWorkspace.GetCompilationAsync(), LoggingHelpers.LogElapsedTime("All old generated files have been deleted"); + CodeModelGenerator.Instance.Emitter.Info("[diag] Before PostProcessAsync()"); await generatedCodeWorkspace.PostProcessAsync(); + CodeModelGenerator.Instance.Emitter.Info("[diag] After PostProcessAsync()"); // Write the generated files to the output directory + CodeModelGenerator.Instance.Emitter.Info("[diag] Before GetGeneratedFilesAsync()"); await foreach (var file in generatedCodeWorkspace.GetGeneratedFilesAsync()) { if (string.IsNullOrEmpty(file.Text)) @@ -115,6 +158,7 @@ await customCodeWorkspace.GetCompilationAsync(), Directory.CreateDirectory(Path.GetDirectoryName(filename)!); await File.WriteAllTextAsync(filename, file.Text); } + CodeModelGenerator.Instance.Emitter.Info("[diag] After GetGeneratedFilesAsync()"); // Write additional output files (e.g. configuration schemas, .targets files) await CodeModelGenerator.Instance.WriteAdditionalFiles(outputPath); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index 431b5443059..c069be1763f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -104,8 +104,10 @@ internal static SyntaxTree GetTree(TypeProvider provider) public async IAsyncEnumerable<(string Name, string Text)> GetGeneratedFilesAsync() { + CodeModelGenerator.Instance.Emitter.Info($"[diag] GetGeneratedFilesAsync: processing {_project.Documents.Count()} documents"); List> documents = new List>(); var memberRemover = new MemberRemoverRewriter(); + int docIndex = 0; foreach (Document document in _project.Documents) { if (!IsGeneratedDocument(document)) @@ -113,9 +115,13 @@ internal static SyntaxTree GetTree(TypeProvider provider) continue; } + CodeModelGenerator.Instance.Emitter.Info($"[diag] GetGeneratedFilesAsync: queuing doc {docIndex}: {document.Name}"); documents.Add(ProcessDocument(document, memberRemover)); + docIndex++; } + CodeModelGenerator.Instance.Emitter.Info($"[diag] GetGeneratedFilesAsync: before Task.WhenAll for {docIndex} docs"); var docs = await Task.WhenAll(documents); + CodeModelGenerator.Instance.Emitter.Info($"[diag] GetGeneratedFilesAsync: after Task.WhenAll"); LoggingHelpers.LogElapsedTime("Roslyn post processing complete"); @@ -262,6 +268,7 @@ internal static Project AddDirectory(Project project, string directory, Func public async Task PostProcessAsync() { + CodeModelGenerator.Instance.Emitter.Info("[diag] PostProcessAsync: creating PostProcessor"); var modelFactory = CodeModelGenerator.Instance.OutputLibrary.ModelFactory.Value; var nonRootTypes = CodeModelGenerator.Instance.NonRootTypes; var postProcessor = new PostProcessor( @@ -269,18 +276,26 @@ public async Task PostProcessAsync() modelFactoryFullName: modelFactory.Type.FullyQualifiedName, additionalNonRootTypeNames: nonRootTypes); + CodeModelGenerator.Instance.Emitter.Info($"[diag] PostProcessAsync: UnreferencedTypesHandling={Configuration.UnreferencedTypesHandling}"); switch (Configuration.UnreferencedTypesHandling) { case Configuration.UnreferencedTypesHandlingOption.KeepAll: + CodeModelGenerator.Instance.Emitter.Info("[diag] PostProcessAsync: KeepAll - skipping"); break; case Configuration.UnreferencedTypesHandlingOption.Internalize: + CodeModelGenerator.Instance.Emitter.Info("[diag] PostProcessAsync: Before InternalizeAsync"); _project = await postProcessor.InternalizeAsync(_project); + CodeModelGenerator.Instance.Emitter.Info("[diag] PostProcessAsync: After InternalizeAsync"); break; case Configuration.UnreferencedTypesHandlingOption.RemoveOrInternalize: + CodeModelGenerator.Instance.Emitter.Info("[diag] PostProcessAsync: Before InternalizeAsync"); _project = await postProcessor.InternalizeAsync(_project); + CodeModelGenerator.Instance.Emitter.Info("[diag] PostProcessAsync: After InternalizeAsync, Before RemoveAsync"); _project = await postProcessor.RemoveAsync(_project); + CodeModelGenerator.Instance.Emitter.Info("[diag] PostProcessAsync: After RemoveAsync"); break; } + CodeModelGenerator.Instance.Emitter.Info("[diag] PostProcessAsync: complete"); } /// diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs index 1302db936ac..5bf718fd838 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs @@ -182,6 +182,8 @@ public CodeWriter Append(FormattableString formattableString) public void WriteMethod(MethodProvider method) { ArgumentNullException.ThrowIfNull(method, nameof(method)); + Console.Error.WriteLine($"[diag] WriteMethod: {method.Signature.Name}"); + Console.Error.Flush(); using (WriteXmlDocs(method.XmlDocs)) { @@ -1064,13 +1066,20 @@ public void WriteArguments(IEnumerable arguments, bool useSingl { AppendRaw("("); var iterator = arguments.GetEnumerator(); + int argIdx = 0; if (iterator.MoveNext()) { - iterator.Current.Write(this); + Console.Error.WriteLine($"[diag] WriteArguments arg {argIdx}: {iterator.Current?.GetType().FullName ?? "NULL"}"); + Console.Error.Flush(); + iterator.Current!.Write(this); + argIdx++; while (iterator.MoveNext()) { AppendRaw(", "); - iterator.Current.Write(this); + Console.Error.WriteLine($"[diag] WriteArguments arg {argIdx}: {iterator.Current?.GetType().FullName ?? "NULL"}"); + Console.Error.Flush(); + iterator.Current!.Write(this); + argIdx++; } } AppendRaw(")"); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/UnsafeBufferSequence.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/UnsafeBufferSequence.cs index 9e76e92afd2..996bd70c054 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/UnsafeBufferSequence.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/UnsafeBufferSequence.cs @@ -17,6 +17,9 @@ internal sealed partial class UnsafeBufferSequence : IBufferWriter, IDispo private volatile UnsafeBufferSegment[] _buffers; // this is an array so items can be accessed by ref private volatile int _count; private readonly int _segmentSize; + // Use a private pool instead of ArrayPool.Shared to avoid mmap-backed allocations + // that can cause SIGSEGV in sandboxed container environments (e.g., Azure App Service). + private static readonly ArrayPool _pool = ArrayPool.Create(); /// /// Initializes a new instance of . @@ -92,7 +95,7 @@ private void ExpandBuffers(int sizeToRent) _buffers.CopyTo(resized, 0); } _buffers = resized; - _buffers[_count].Array = ArrayPool.Shared.Rent(sizeToRent); + _buffers[_count].Array = _pool.Rent(sizeToRent); _count = bufferCount == 1 ? bufferCount : _count + 1; } } @@ -125,7 +128,7 @@ public void Dispose() for (int i = 0; i < bufferCountToFree; i++) { - ArrayPool.Shared.Return(buffersToFree[i].Array); + _pool.Return(buffersToFree[i].Array); } } diff --git a/packages/http-client-csharp/package.json b/packages/http-client-csharp/package.json index d7e2d7198c4..15ac2556638 100644 --- a/packages/http-client-csharp/package.json +++ b/packages/http-client-csharp/package.json @@ -25,6 +25,10 @@ "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", 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..02453cab263 --- /dev/null +++ b/packages/http-client-csharp/playground-server/Dockerfile @@ -0,0 +1,47 @@ +# 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 and test data +RUN cp -r dist/generator /app/generator +RUN mkdir -p /app/test-data && \ + cp generator/TestProjects/Spector/http/routes/tspCodeModel.json /app/test-data/ && \ + cp generator/TestProjects/Spector/http/routes/Configuration.json /app/test-data/ + +# 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 . + +# Install SSH and lldb for remote debugging +RUN apt-get update && apt-get install -y openssh-server lldb-18 \ + && echo "root:Docker!" | chpasswd \ + && mkdir -p /run/sshd \ + && apt-get clean && rm -rf /var/lib/apt/lists/* +COPY playground-server/sshd_config /etc/ssh/ + +ENV DOTNET_ENVIRONMENT=Production +ENV GENERATOR_PATH=/app/generator/Microsoft.TypeSpec.Generator.dll +ENV TMPDIR=/tmp +# Collect full dump on crash +ENV DOTNET_DbgEnableMiniDump=1 +ENV DOTNET_DbgMiniDumpType=4 +ENV DOTNET_DbgMiniDumpName=/home/coredump.%p + +EXPOSE 5174 2222 +COPY playground-server/entrypoint.sh /entrypoint.sh +COPY playground-server/test-generator.sh /app/test-generator.sh +RUN chmod +x /entrypoint.sh /app/test-generator.sh +ENTRYPOINT ["/entrypoint.sh"] 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..df50a1af034 --- /dev/null +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -0,0 +1,245 @@ +// 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; } + + // Check for core dumps in /tmp and subdirectories + var dumpFiles = new List(); + if (Directory.Exists("/home")) + { + foreach (var f in Directory.GetFiles("/home", "coredump*", SearchOption.TopDirectoryOnly)) + dumpFiles.Add(f); + } + + 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(), + coreDumps = dumpFiles + }); +}); + +app.MapGet("/coredump/{filename}", (string filename) => +{ + var path = Path.Combine("/home", filename); + if (!File.Exists(path) || !filename.StartsWith("coredump")) + return Results.NotFound(); + return Results.File(path, "application/octet-stream", filename); +}); + +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); + + var exitCode = -1; + + 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)!; + + 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); + + 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 + { + // Clean up temp directory + // Keep temp dir on failure for manual debugging via SSH + if (exitCode == 0) + { + try { Directory.Delete(tempDir, recursive: true); } catch { } + } + else + { + Console.WriteLine($"Keeping temp dir for debugging: {tempDir}"); + } + } +}).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/entrypoint.sh b/packages/http-client-csharp/playground-server/entrypoint.sh new file mode 100644 index 00000000000..2d0a6893bff --- /dev/null +++ b/packages/http-client-csharp/playground-server/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Start SSH daemon for App Service remote access +/usr/sbin/sshd + +# Start the playground server +exec dotnet /app/playground-server.dll 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/packages/http-client-csharp/playground-server/sshd_config b/packages/http-client-csharp/playground-server/sshd_config new file mode 100644 index 00000000000..27e95ecca7c --- /dev/null +++ b/packages/http-client-csharp/playground-server/sshd_config @@ -0,0 +1,12 @@ +Port 2222 +ListenAddress 0.0.0.0 +LoginGraceTime 180 +X11Forwarding yes +Ciphers aes128-cbc,3des-cbc,aes256-cbc,aes128-ctr,aes192-ctr,aes256-ctr +MACs hmac-sha1,hmac-sha1-96 +StrictModes yes +SyslogFacility DAEMON +PasswordAuthentication yes +PermitEmptyPasswords no +PermitRootLogin yes +Subsystem sftp internal-sftp diff --git a/packages/http-client-csharp/playground-server/test-generator.sh b/packages/http-client-csharp/playground-server/test-generator.sh new file mode 100644 index 00000000000..878d221fe36 --- /dev/null +++ b/packages/http-client-csharp/playground-server/test-generator.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Test the generator manually in the App Service container +# Usage: bash /app/test-generator.sh + +DIR=/tmp/test-gen-$$ +mkdir -p "$DIR" + +# Copy test data from bundled Spector routes test +cp /app/test-data/tspCodeModel.json "$DIR/" +cp /app/test-data/Configuration.json "$DIR/" + +echo "Running generator in $DIR..." +dotnet --roll-forward Major /app/generator/Microsoft.TypeSpec.Generator.dll "$DIR" -g ScmCodeModelGenerator --new-project +EXIT=$? + +echo "Exit code: $EXIT" +if [ $EXIT -eq 0 ]; then + echo "Generated files:" + find "$DIR" -type f | head -20 + rm -rf "$DIR" +else + echo "FAILED - temp dir preserved at $DIR" +fi 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,