Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 16 additions & 45 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
92 changes: 63 additions & 29 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,42 +1,33 @@
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
push:
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
Expand All @@ -63,33 +54,76 @@ 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

- name: Upload NuGet package as artifact
if: ${{ inputs.dry-run }}
uses: actions/upload-artifact@v4
with:
name: typeshim-nuget-package-${{ steps.ver.outputs.version }}
path: .\.artifacts\*.nupkg
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 }}
53 changes: 44 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
<h1 align=center tabindex=-1>TypeShim</h1>
<p align=center tabindex=-1>
<i>Strongly-typed .NET-JS interop facade generation</i>
<i>Bridge .NET WebAssembly and TypeScript with fast, reliable, and type-safe codegen.</i>
</p>

<img align="right" tabindex=-1 src="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/ArcadeMode/0f24ed28316a25f6293d5771a247f19d/raw/typeshim-tests-badge.json" alt="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
Expand Down Expand Up @@ -294,6 +294,8 @@ TypeShim aims to continue to broaden its type support. Suggestions and contribut
| `Dictionary<TKey, TValue>` | `?` | 💡 | under consideration |
| `(T1, T2)` | `[T1, T2]` | 💡 | under consideration |

Table 1. TypeShim supported interop types

| .NET Marshalled Type | Mapped Type | Support | Note |
|----------------------|-------------|--------|------|
| `Boolean` | `Boolean` | ✅ | |
Expand Down Expand Up @@ -328,6 +330,8 @@ TypeShim aims to continue to broaden its type support. Suggestions and contribut
| `Func<T1, T2, TResult>` | `Function`| ✅ | |
| `Func<T1, T2, T3, TResult>` | `Function` | ✅ | |

Table 2. TypeShim support for .NET-JS interop types

*<sub>For `[TSExport]` classes</sub>

## Run the sample
Expand Down Expand Up @@ -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

### <a name="limitations"></a>Limitations

TSExports are subject to minimal, but some, constraints.
Expand All @@ -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.

### <a name="performance"></a>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.
Expand Down
12 changes: 7 additions & 5 deletions src/TypeShim/TsExportAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,27 @@
/// <b>TSExport classes must be non-static.</b>
/// <br/>
/// <br/>
/// 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.<br/>
/// Whenever a TSExport class crosses the interop boundary, TypeShim automatically wraps it in an appropriate TypeScript class matching the C# class's public signature.
/// <code>
/// [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;
/// }
/// </code>
///
/// Usage in TypeScript looks like this:
/// Can be used in TypeScript like this:
///
/// <code>
/// 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
/// </code>
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
Expand Down
8 changes: 6 additions & 2 deletions src/TypeShim/TypeShim.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,25 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<DebugType Condition="'$(Configuration)' == 'Release'">none</DebugType>
<DebugSymbols Condition="'$(Configuration)' == 'Release'">false</DebugSymbols>
</PropertyGroup>

<PropertyGroup>
<PackageId>TypeShim</PackageId>
<Version>0.0.1</Version>
<Authors>ArcadeMode</Authors>
<Product>TypeShim</Product>
<Description>Typesafe .NET ↔︎ TypeScript interop</Description>
<PackageTags>wasm, csharp, typescript, interop, generated</PackageTags>
<PackageTags>wasm, csharp, typescript, interop, generator</PackageTags>
<RepositoryUrl>https://github.com/ArcadeMode/TypeShim</RepositoryUrl>
<PackageProjectUrl>https://github.com/ArcadeMode/TypeShim</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
<Content Include="../../README.md" Pack="true" PackagePath="" />
<Content Include="bin/pack/build/**/*.*" Pack="true" PackagePath="build" />
<Content Include="bin/pack/analyzers/**/*.*" Pack="true" PackagePath="analyzers/dotnet/cs" />
</ItemGroup>
Expand Down
Loading
Loading