diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4893f25..de90cf7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,12 +1,15 @@ name: Main on: - workflow_dispatch: workflow_call: + outputs: + badge-color: + description: 'Color for the test results badge' + value: ${{ jobs.publish-results.outputs.badge-color }} + badge-message: + description: 'Text for the test results badge' + value: ${{ jobs.publish-results.outputs.badge-message }} pull_request: - push: - branches: - - master permissions: checks: write @@ -98,32 +101,15 @@ jobs: !**/*.dSYM/** if-no-files-found: error retention-days: 1 - - combine: - name: ๐Ÿ“ฆ Publish Artifacts - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Download all RID artifacts - uses: actions/download-artifact@v4 - with: - pattern: pack-build-* - path: pack - merge-multiple: true - - - name: Upload combined pack directory - uses: actions/upload-artifact@v4 - with: - name: typeshim-pack - path: pack/** publish-results: name: ๐Ÿงพ Publish Results runs-on: ubuntu-latest needs: build if: ${{ always() && !cancelled() }} + outputs: + badge-color: ${{ steps.badge.outputs.color }} + badge-message: ${{ steps.badge.outputs.message }} steps: - name: Download test results uses: actions/download-artifact@v4 @@ -140,28 +126,13 @@ jobs: results/**/e2e-report-*.xml - name: Determine badge content - if: ${{ github.event_name == 'push' }} - shell: bash + id: badge + shell: pwsh run: | - conclusion='${{ fromJSON(steps.test-results.outputs.json).conclusion }}' - if [ "$conclusion" = "success" ]; then - echo "BADGE_COLOR=#26cc23" >> "$GITHUB_ENV" - echo "BADGE_MESSAGE=${{ fromJSON( steps.test-results.outputs.json ).formatted.stats.runs_succ }} passing" >> "$GITHUB_ENV" - else - echo "BADGE_COLOR=#cc5623" >> "$GITHUB_ENV" - echo "BADGE_MESSAGE=${{ fromJSON( steps.test-results.outputs.json ).formatted.stats.runs_fail }}/${{ fromJSON( steps.test-results.outputs.json ).formatted.stats.runs }} failing" >> "$GITHUB_ENV" - fi - - - name: Update badge - if: ${{ github.event_name == 'push' }} - uses: schneegans/dynamic-badges-action@v1.7.0 - with: - auth: ${{ secrets.GIST_TOKEN }} - gistID: '0f24ed28316a25f6293d5771a247f19d' - filename: typeshim-tests-badge.json - label: Tests - message: ${{ env.BADGE_MESSAGE }} - color: ${{ env.BADGE_COLOR }} + $path = if ("${{ fromJSON(steps.test-results.outputs.json).conclusion }}" -eq "success") { '#26cc23' } else { '#cc5623' } + $message = if ("${{ fromJSON(steps.test-results.outputs.json).conclusion }}" -eq "success") { "${{ fromJSON( steps.test-results.outputs.json ).formatted.stats.runs_succ }} passing" } else { "${{ fromJSON( steps.test-results.outputs.json ).formatted.stats.runs_fail }}/${{ fromJSON( steps.test-results.outputs.json ).formatted.stats.runs }} failing" } + "color=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "message=$message" | Out-File -FilePath $env:GITHUB_OUTPUT -Append benchmarks: name: ๐Ÿ Benchmark Generator diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 65480e2..71ced48 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,15 +1,15 @@ -name: Publish NuGet +name: Publish on: workflow_dispatch: inputs: version: - description: 'Tag to publish v[0-9]+.[0-9]+.[0-9]+*' + description: 'Tag to publish v1.2.3[-prerelease]' required: true default: '' type: string dry-run: - description: 'Perform a dry run without pushing to NuGet' + description: 'Dry run' required: false default: false type: boolean @@ -17,26 +17,17 @@ on: tags: - "v[0-9]+.[0-9]+.[0-9]+*" -jobs: - build: - name: ๐Ÿ› ๏ธ Build and Pack - uses: ./.github/workflows/main.yml +permissions: + checks: write + pull-requests: write - publish: - name: ๐Ÿ“ฆ Publish to NuGet - needs: build +jobs: + version: + name: Determine Version runs-on: ubuntu-latest - permissions: - contents: read + outputs: + version: ${{ steps.ver.outputs.version }} steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - global-json-file: src/global.json - - name: Determine and validate version id: ver shell: pwsh @@ -63,27 +54,55 @@ jobs: Write-Host "Version: $ver" "version=$ver" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - - name: Download pack artifact + build: + name: Main + uses: ./.github/workflows/main.yml + + publish: + name: ๐Ÿ“ฆ Publish to NuGet + needs: [version, build] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: src/global.json + + - name: Restore + run: dotnet restore src/TypeShim/TypeShim.csproj + + - name: Download all RID artifacts uses: actions/download-artifact@v4 with: - pattern: typeshim-pack + pattern: pack-build-* path: src/TypeShim/bin/pack + merge-multiple: true + + - name: Build TypeShim library + run: > + dotnet build src/TypeShim/TypeShim.csproj + -c Release + --no-restore - name: Pack TypeShim - env: - PKG_VERSION: ${{ steps.ver.outputs.version }} run: > dotnet pack src/TypeShim/TypeShim.csproj -c Release - -o .\.artifacts + -o . --no-build - -p:Version=$env:PKG_VERSION + -p:Version=${{ needs.version.outputs.version }} -p:ContinuousIntegrationBuild=true - name: Push to GitHub Packages if: ${{ !inputs.dry-run }} run: > - dotnet nuget push .\.artifacts\*.nupkg + dotnet nuget push + TypeShim.${{ needs.version.outputs.version }}.nupkg -k ${{ secrets.NUGET_KEY }} -s https://api.nuget.org/v3/index.json @@ -91,5 +110,20 @@ jobs: if: ${{ inputs.dry-run }} uses: actions/upload-artifact@v4 with: - name: typeshim-nuget-package-${{ steps.ver.outputs.version }} - path: .\.artifacts\*.nupkg \ No newline at end of file + name: typeshim-nupkg-${{ needs.version.outputs.version }} + path: TypeShim.${{ needs.version.outputs.version }}.nupkg + + badge: + name: ๐Ÿท๏ธ Update Readme Badge + needs: build + runs-on: ubuntu-latest + steps: + - name: Update badge + uses: schneegans/dynamic-badges-action@v1.7.0 + with: + auth: ${{ secrets.GIST_TOKEN }} + gistID: '0f24ed28316a25f6293d5771a247f19d' + filename: typeshim-tests-badge.json + label: Tests + message: ${{ needs.build.outputs.badge-message }} + color: ${{ needs.build.outputs.badge-color }} \ No newline at end of file diff --git a/README.md b/README.md index 321d62d..8db3795 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@

TypeShim

- Strongly-typed .NET-JS interop facade generation + Bridge .NET WebAssembly and TypeScript with fast, reliable, and type-safe codegen.

Test status ## Why TypeShim -With TypeShim you can use your .NET WASM project with TypeScript _without having to write a single JSExport yourself_. All interop code (C# & TS) is generated at build-time to provide reliability and type safety. TypeShim's class level exports make for a natural programming experience with familiar syntax. No bells and whistles, just your .NET WASM project accessible through a neat TypeScript facade. +With TypeShim you can interop between .NET WebAssembly and TypeScript with class level exports. It takes just one `[TSExport]` to bring your class to TypeScript with all its constructors, methods and properties. TypeShim aims to provide a natural programming experience with familiar syntax, without any unnecessary bells and whistles. It just makes your .NET classes available in TypeScript, no hassle. -Simply install TypeShim in your .NET WASM project, drop a `[TSExport]` on your C# classes and _voilร _: .NET-JS interop is generated at build time including a powerful TypeScript library matching your C# classes exactly. +The interop code on both the C# & TypeScript side is build-time generated in a flash to provide an up-to-date and type safe interop boundary that just _works_. Thoroughly tested from codegen to runtime to deliver reliability. -## Features at a glance +## At a glance -- ๐Ÿญ No-nonsense [interop](#concepts) generation. -- ๐ŸŒฑ Opt-in with just one attribute. -- ๐Ÿค– Export full classes: constructors, methods and properties. -- ๐Ÿ’ฐ [Enriched type marshalling](#enriched-type-support). -- ๐Ÿ›ก Type-safety across the interop boundary. +- ๐Ÿ“ค Class level exports. +- ๐Ÿ’Ž [Rich type support](#enriched-type-support). +- โœ No-nonsense [interop](#concepts) codegen. +- ๐Ÿฆพ Thoroughly validated for correctness +- โšก Tuned for [high performance](#performance) - ๐Ÿ‘ [Easy setup](#installing) ## Samples @@ -294,6 +294,8 @@ TypeShim aims to continue to broaden its type support. Suggestions and contribut | `Dictionary` | `?` | ๐Ÿ’ก | under consideration | | `(T1, T2)` | `[T1, T2]` | ๐Ÿ’ก | under consideration | +Table 1. TypeShim supported interop types + | .NET Marshalled Type | Mapped Type | Support | Note | |----------------------|-------------|--------|------| | `Boolean` | `Boolean` | โœ… | | @@ -328,6 +330,8 @@ TypeShim aims to continue to broaden its type support. Suggestions and contribut | `Func` | `Function`| โœ… | | | `Func` | `Function` | โœ… | | +Table 2. TypeShim support for .NET-JS interop types + *For `[TSExport]` classes ## Run the sample @@ -357,6 +361,8 @@ TypeShim is configured through MSBuild properties, you may provide these through | `TypeShim_GeneratedDir` | `TypeShim` | Directory path (relative to `IntermediateOutputPath`) for generated `YourClass.Interop.g.cs` files. | `TypeShim` | | `TypeShim_MSBuildMessagePriority` | `Normal` | MSBuild message priority. Set to High for debugging. | `Low`, `Normal`, `High` | +Table 3. Configuration options + ### Limitations TSExports are subject to minimal, but some, constraints. @@ -365,6 +371,35 @@ TSExports are subject to minimal, but some, constraints. - By default, JSExport yields value semantics for Array instances, this is one reference type that is atypical. It is under consideration to be addressed but an effective alternative is to define your own List class to preserve reference semantics. - Classes with generic type parameters can not be part of interop codegen at this time. +### Performance + +TypeShim has been optimized to achieve average codegen times of ~1 ms per class in a set of benchmarks going up to 200 classes. By optimizing the implementation and providing NativeAOT builds via the NuGet package, most users should see end-to-end codegen times of roughly 50โ€“200 ms for projects with 25โ€“200 classes. Every PR validates both AOT and JIT performance to help maintain these numbers. + +Performance is prioritized to minimize build-time impact and deliver the best possible experience for TypeShim users. Secondly it was a good excuse to play around with profiling tools and get some hands on experience with performance optimization and NativeAOT. + +The earlier versions of TypeShim used regular JIT builds which suffered expensive runtime start times and an inability to warm-up so even smaller projects would require more than 1 second for codegen. Switching to NativeAOT brought this down to the quarterisecond range and after several optimizations has been reduced to below a tenth of a second in many cases. + +Results from the continuous benchmarking that is now part of every pull request are shown in Table 4. The 0 classes case demonstrates the overhead of starting the process without doing any work. + +| Method | Compilation | ClassCount | Mean | Error | StdDev | +|--------- |------------ |-----------:|------------:|----------:|----------:| +| **Generate** | **AOT** | **0** | **14.02 ms** | **1.319 ms** | **0.873 ms** | +| **Generate** | **AOT** | **1** | **31.35 ms** | **0.969 ms** | **0.641 ms** | +| **Generate** | **AOT** | **10** | **31.82 ms** | **1.683 ms** | **1.113 ms** | +| **Generate** | **AOT** | **25** | **45.32 ms** | **1.565 ms** | **1.035 ms** | +| **Generate** | **AOT** | **50** | **56.50 ms** | **1.103 ms** | **0.730 ms** | +| **Generate** | **AOT** | **100** | **91.60 ms** | **2.294 ms** | **1.517 ms** | +| **Generate** | **AOT** | **200** | **93.92 ms** | **1.553 ms** | **1.027 ms** | +| **Generate** | **JIT** | **0** | **42.07 ms** | **0.687 ms** | **0.454 ms** | +| **Generate** | **JIT** | **1** | **813.62 ms** | **10.321 ms** | **6.827 ms** | +| **Generate** | **JIT** | **10** | **814.93 ms** | **9.107 ms** | **6.024 ms** | +| **Generate** | **JIT** | **25** | **862.08 ms** | **11.549 ms** | **7.639 ms** | +| **Generate** | **JIT** | **50** | **900.00 ms** | **14.144 ms** | **9.355 ms** | +| **Generate** | **JIT** | **100** | **1,014.10 ms** | **12.046 ms** | **7.968 ms** | +| **Generate** | **JIT** | **200** | **986.96 ms** | **22.021 ms** | **14.565 ms** | + +Table 4. Benchmark results on an AMD EPYC 7763 2.45GHz Github Actions runner. + ## Contributing Contributions are welcome. diff --git a/src/TypeShim/TsExportAttribute.cs b/src/TypeShim/TsExportAttribute.cs index ea518be..01a05af 100644 --- a/src/TypeShim/TsExportAttribute.cs +++ b/src/TypeShim/TsExportAttribute.cs @@ -4,25 +4,27 @@ /// TSExport classes must be non-static. ///
///
-/// TSExport classes define your interop API surface. You can use static and instance members in your TSExport classes, you will use them as static or instance in TypeScript too. -/// Whenever a method returns an instance of a TSExport class, TypeShim generates a TypeScript proxy class that wraps the interop calls to the underlying C# instance. +/// TSExport classes define your .NET-TS API surface. Public methods, properties and constructors, both static and member, are accessible from the generated TypeScript library.
+/// Whenever a TSExport class crosses the interop boundary, TypeShim automatically wraps it in an appropriate TypeScript class matching the C# class's public signature. /// /// [TSExport] /// public class MyCounter /// { -/// public static MyCounter GetCounter() => new MyCounter(); /// public int Count { get; private set; } = 0; /// public string Increment() => $"Hello from C#, Count is now {++Count}"; +/// public bool EqualsCount(MyCounter other) => Count == other.Count; /// } /// /// -/// Usage in TypeScript looks like this: +/// Can be used in TypeScript like this: /// /// -/// const counter: MyCounter.Proxy = MyCounter.GetCounter(); +/// const counter: MyCounter = new MyCounter(); /// console.log(counter.Count); // 0 /// console.log(counter.Increment()); // "Hello from C#, Count is now 1" /// console.log(counter.Count); // 1 +/// const counter2: MyCounter = new MyCounter(); +/// console.log(counter.EqualsCount(counter2)); // false /// /// [AttributeUsage(AttributeTargets.Class)] diff --git a/src/TypeShim/TypeShim.csproj b/src/TypeShim/TypeShim.csproj index 204e0b9..7f4afdf 100644 --- a/src/TypeShim/TypeShim.csproj +++ b/src/TypeShim/TypeShim.csproj @@ -7,7 +7,9 @@ enable enable en - + none + false + TypeShim @@ -15,13 +17,15 @@ ArcadeMode TypeShim Typesafe .NET โ†”๏ธŽ TypeScript interop - wasm, csharp, typescript, interop, generated + wasm, csharp, typescript, interop, generator https://github.com/ArcadeMode/TypeShim https://github.com/ArcadeMode/TypeShim MIT + README.md + diff --git a/src/TypeShim/TypeShim.targets b/src/TypeShim/TypeShim.targets index c9b5fa9..bb0ecbb 100644 --- a/src/TypeShim/TypeShim.targets +++ b/src/TypeShim/TypeShim.targets @@ -60,7 +60,7 @@ - + <_TypeShim_TypeScriptOutputFilePathSegment>$([System.IO.Path]::Combine('$(TypeShim_TypeScriptOutputDirectory)', '$(TypeShim_TypeScriptOutputFileName)')) @@ -68,8 +68,8 @@ <_TypeShim_CSharpOutputDirectoryPath>$([System.IO.Path]::Combine('$(IntermediateOutputPath)', '$(TypeShim_GeneratedDir)')) - - + + @@ -80,11 +80,11 @@ Command=""$(_TypeShimGeneratorExePath)" "@(Compile->'%(FullPath)', ';')" "$(_TypeShim_CSharpOutputDirectoryPath)" "$(_TypeShim_TypeScriptOutputFilePath)" "$(_TargetingPackRefDir)"" WorkingDirectory="$(ProjectDir)" IgnoreExitCode="false" /> - + - + @@ -98,6 +98,6 @@ - + \ No newline at end of file