Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f4f4c1c
Upload playground standalone emitters
timotheeguerin Mar 31, 2026
bddc4f5
use latest instead
timotheeguerin Mar 31, 2026
bb15e69
Apply suggestion from @timotheeguerin
timotheeguerin Apr 1, 2026
5883d12
fix: strip leading slash from PackagePath in upload-bundled-emitter
JoshLove-msft Apr 1, 2026
dd0c170
Merge branch 'main' of https://github.com/Microsoft/typespec into sta…
timotheeguerin Apr 2, 2026
2814ed0
fix: build emitter before playground bundle upload
JoshLove-msft Apr 2, 2026
0bbb3c7
fix: use npm run build instead of build:emitter
JoshLove-msft Apr 2, 2026
32bb4e7
feat(http-client-csharp): add playground support
JoshLove-msft Apr 2, 2026
6d29a07
fix: use build:emitter instead of build (no .NET SDK needed)
JoshLove-msft Apr 2, 2026
fb1e8ac
fix: parameterize build script for playground bundle
JoshLove-msft Apr 2, 2026
1936c27
fix(bundler): use empty shims for Node.js modules that break in browser
JoshLove-msft Apr 3, 2026
3d68882
Revert "fix(bundler): use empty shims for Node.js modules that break …
JoshLove-msft Apr 3, 2026
c39bd69
fix(http-client-csharp): use browser override for playground
JoshLove-msft Apr 3, 2026
7b533fb
fix: update test to import _validateDotNetSdk from emit-generate
JoshLove-msft Apr 3, 2026
1f72597
fix: update emitter tests for browser override refactor
JoshLove-msft Apr 3, 2026
2b652f0
fix: use serializeCodeModel for proper / tracking
JoshLove-msft Apr 3, 2026
09bc7f8
fix: remove _validateDotNetSdk re-export from emitter.ts
JoshLove-msft Apr 3, 2026
bb5060b
fix: split _validateDotNetSdk tests to separate file
JoshLove-msft Apr 3, 2026
034c1e0
fix: remove duplicate generate import
JoshLove-msft Apr 3, 2026
d1ce21e
fix: sync version from published package before bundle upload
JoshLove-msft Apr 3, 2026
3f8ddc5
feat: deploy playground server from publish pipeline
JoshLove-msft Apr 3, 2026
fecb3d8
fix: use shared infra constants for server deploy
JoshLove-msft Apr 3, 2026
c1012a0
fix: don't default to localhost in browser stub
JoshLove-msft Apr 4, 2026
7909ea4
feat: set playground server URL on the website
JoshLove-msft Apr 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions eng/emitters/pipelines/templates/stages/emitter-stages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ parameters:
type: string
default: "3.12"

# Whether to bundle and upload the emitter package to the playground package storage.
- name: UploadPlaygroundBundle
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
Expand Down Expand Up @@ -333,6 +356,67 @@ stages:
ArtifactPath: $(buildArtifactsPath)
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 }}
- script: pnpm install --filter "@typespec/bundle-uploader..."
displayName: Install bundle-uploader dependencies
workingDirectory: $(Build.SourcesDirectory)
- script: pnpm --filter "@typespec/bundle-uploader..." build
displayName: Build bundle-uploader
workingDirectory: $(Build.SourcesDirectory)
- task: AzureCLI@1
displayName: Upload playground bundle
inputs:
azureSubscription: "Azure SDK Engineering System"
scriptLocation: inlineScript
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="typespec"
RESOURCE_GROUP="typespec"
APP_NAME="${{ parameters.PlaygroundServerAppName }}"
IMAGE="$REGISTRY.azurecr.io/$APP_NAME:$(Build.BuildId)"

echo "Building Docker image: $IMAGE"
az acr build \
--registry "$REGISTRY" \
--image "$APP_NAME:$(Build.BuildId)" \
--file ${{ parameters.PlaygroundServerDockerfile }} \
$(Build.SourcesDirectory)/${{ parameters.PackagePath }}

echo "Deploying to Container Apps"
az containerapp update \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--image "$IMAGE"

echo "Deployed successfully"
workingDirectory: $(Build.SourcesDirectory)

templateContext:
outputs:
- output: pipelineArtifact
Expand Down
17 changes: 17 additions & 0 deletions eng/emitters/scripts/upload-bundled-emitter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// @ts-check
import { resolve } from "path";
import { bundleAndUploadStandalonePackage } from "../../../packages/bundle-uploader/dist/src/index.js";
import { repoRoot } from "../../common/scripts/helpers.js";

const packageRelativePath = process.argv[2];
if (!packageRelativePath) {
console.error("Usage: node upload-bundled-emitter.js <package-path>");

Check warning on line 8 in eng/emitters/scripts/upload-bundled-emitter.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
console.error(" e.g. node upload-bundled-emitter.js packages/http-client-csharp");

Check warning on line 9 in eng/emitters/scripts/upload-bundled-emitter.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
process.exit(1);
}

// Strip leading "/" so resolve() doesn't treat it as an absolute path.
// Pipeline PackagePath values start with "/" (e.g. "/packages/http-client-csharp").
const packagePath = resolve(repoRoot, packageRelativePath.replace(/^\//, ""));

await bundleAndUploadStandalonePackage({ packagePath });
48 changes: 46 additions & 2 deletions packages/bundle-uploader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,47 @@ function logSuccess(message: string) {
logInfo(pc.green(`✔ ${message}`));
}

export interface BundleAndUploadStandalonePackageOptions {
/**
* Absolute path to the package directory.
*/
packagePath: string;
}

/**
* Bundle and upload a standalone package that is not part of the pnpm workspace.
* Uploads the bundle files and writes a `latest.json` under the package's blob directory
* (e.g. `@typespec/http-client-csharp/latest.json`).
*/
export async function bundleAndUploadStandalonePackage({
packagePath,
}: BundleAndUploadStandalonePackageOptions) {
const bundle = await createTypeSpecBundle(packagePath);
const manifest = bundle.manifest;
logInfo(`Bundling standalone package: ${manifest.name}@${manifest.version}`);

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

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

const importMap: Record<string, string> = {};
for (const [key, value] of Object.entries(result.imports)) {
importMap[joinUnix(manifest.name, key)] = value;
}

await uploader.updatePackageLatest(manifest.name, {
version: manifest.version,
imports: importMap,
});
logSuccess(`Updated ${manifest.name}/latest.json for version ${manifest.version}.`);
}

export interface BundleAndUploadPackagesOptions {
repoRoot: string;
/**
Expand Down Expand Up @@ -85,9 +126,12 @@ export async function bundleAndUploadPackages({
}
}
logInfo(`Import map for ${indexVersion}:`, importMap);
await uploader.updateIndex(indexName, {
const index = {
version: indexVersion,
imports: importMap,
});
};
await uploader.updateIndex(indexName, index);
logSuccess(`Updated index for version ${indexVersion}.`);
await uploader.updateLatestIndex(indexName, index);
logSuccess(`Updated latest index for ${indexName}.`);
}
52 changes: 52 additions & 0 deletions packages/bundle-uploader/src/upload-browser-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,58 @@ export class TypeSpecBundledPackageUploader {
});
}

async getLatestIndex(name: string): Promise<PackageIndex | undefined> {
const blob = this.#container.getBlockBlobClient(`indexes/${name}/latest.json`);
if (await blob.exists()) {
const response = await blob.download();
const body = await response.blobBody;
const existingContent = await body?.text();
if (existingContent) {
return JSON.parse(existingContent);
}
}
return undefined;
}

async updateLatestIndex(name: string, index: PackageIndex) {
const blob = this.#container.getBlockBlobClient(`indexes/${name}/latest.json`);
const content = JSON.stringify(index);
await blob.upload(content, content.length, {
blobHTTPHeaders: {
blobContentType: "application/json; charset=utf-8",
},
});
}

/** Read the latest.json for a package from `{pkgName}/latest.json`. */
async getPackageLatest(pkgName: string): Promise<PackageIndex | undefined> {
const blob = this.#container.getBlockBlobClient(
normalizePath(join(pkgName, "latest.json")),
);
if (await blob.exists()) {
const response = await blob.download();
const body = await response.blobBody;
const existingContent = await body?.text();
if (existingContent) {
return JSON.parse(existingContent);
}
}
return undefined;
}

/** Write the latest.json for a package at `{pkgName}/latest.json`. */
async updatePackageLatest(pkgName: string, index: PackageIndex) {
const blob = this.#container.getBlockBlobClient(
normalizePath(join(pkgName, "latest.json")),
);
const content = JSON.stringify(index);
await blob.upload(content, content.length, {
blobHTTPHeaders: {
blobContentType: "application/json; charset=utf-8",
},
});
}

async #uploadManifest(manifest: BundleManifest) {
try {
const blob = this.#container.getBlockBlobClient(
Expand Down
12 changes: 11 additions & 1 deletion packages/http-client-csharp/emitter/src/code-model-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import { CSharpEmitterContext } from "./sdk-context.js";
import { CodeModel } from "./type/code-model.js";
import { Configuration } from "./type/configuration.js";

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

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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
);
}
}
Loading
Loading