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
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default defineConfig({
{ text: "Interop Modules", link: "/guide/interop-modules" },
{ text: "Interop Instances", link: "/guide/interop-instances" },
{ text: "Preferences", link: "/guide/preferences" },
{ text: "Specialization", link: "/guide/specialization" },
{ text: "Build Configuration", link: "/guide/build-config" },
{ text: "Sideloading", link: "/guide/sideloading" },
{ text: "NativeAOT-LLVM", link: "/guide/llvm" }
Expand Down
111 changes: 111 additions & 0 deletions docs/guide/specialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Specialization

Bootsharp marshals every type automatically based on the convention: types with immutable/value semantics are [serialized by value](/guide/serialization), and others are [passed by reference](/guide/interop-instances).

It's possible to customize the behaviour with **specialization** are redefine how a particular CLR type crosses the interop boundary and what surface it exposes on the other side.

## How It Works

A specialization is a pair of classes describing a custom interop surface for a specific CLR type — one for each direction:

- **Export** (C# → JS) — a class annotated with `[SpecializeExport(typeof(T))]` and inherited from `SpecializedExport`. Bootsharp wraps an exported instance of the specialized type into this class before it crosses to JavaScript.
- **Import** (JS → C#) — an abstract class annotated with `[SpecializeImport(typeof(T))]` and inherited from `SpecializedImport`. Bootsharp uses it as the base of the generated interop proxy and treats its abstract members as the interop surface wired to JavaScript.

The two halves are paired: the export half implements every abstract member declared on the import half, so the same shape is exposed in both directions.

To override how Bootsharp marshals a type declare a specialization pair with the same attributes. Here's an example for `IComparer<T>`:

```csharp
[SpecializeImport(typeof(IComparer<>))]
public abstract class ComparerImport<T> (int id)
: SpecializedImport(id), IComparer<T>
{
public abstract int Compare (T? x, T? y);
}

[SpecializeExport(typeof(IComparer<>))]
public class ComparerExport<T> (IComparer<T> cmp)
: SpecializedExport(cmp)
{
public int Compare (T? x, T? y) => cmp.Compare(x, y);
}
```

The import half declares the interop surface (`Compare`) as abstract members; Bootsharp generates a proxy that forwards them to JavaScript and, since the class implements `IComparer<T>`, the proxy is usable as one on the C# side.

On the JavaScript side a comparer is just an object matching the declared surface:

```ts
import { Program } from "bootsharp";

Program.provideComparer = () => ({
compare: (x, y) => x < y ? -1 : x > y ? 1 : 0
});

const comparer = Program.getComparer();
comparer.compare("a", "b"); // -1
```

## Injecting JavaScript

The `[SpecializeImport]` attribute accepts optional `JS` and `Decl` snippets that are spliced verbatim into the generated JavaScript proxy class and its TypeScript declaration. This lets the imported proxy satisfy JS-side contracts that aren't expressible through the C# abstract members alone — for example, injecting an iterator:

```csharp
[SpecializeImport(typeof(ICustomCollection<>),
JS: "[Symbol.iterator]() { return this.copy()[Symbol.iterator](); }",
Decl: "[Symbol.iterator](): IterableIterator<T>;")]
```

## Unwrapping

An import specializer normally *is* the value handed to C# — the generated proxy implements the specialized interface, so it can stand in for it directly (the way `ComparerImport<T>` above serves as an `IComparer<T>`).

That doesn't work when the specialized type can't be implemented by a proxy, such as a value type like `CancellationToken`. In that case the proxy exposes the JavaScript-side surface as abstract members and overrides `SpecializedImport.Unwrap()` to build the concrete value from them:

```csharp
[SpecializeImport(typeof(CancellationToken))]
public abstract class CancellationTokenImport (int id) : SpecializedImport(id)
{
public abstract bool IsCancellationRequested { get; }
public abstract event Action OnCancellationRequested;

private CancellationTokenSource? src;

protected internal override object Unwrap ()
{
if (src != null) return src.Token;
src = new();
if (IsCancellationRequested) src.Cancel();
else OnCancellationRequested += src.Cancel;
return src.Token;
}
}

[SpecializeExport(typeof(CancellationToken))]
public sealed class CancellationTokenExport : SpecializedExport
{
public bool IsCancellationRequested => ct.IsCancellationRequested;
public event Action? OnCancellationRequested;

private readonly CancellationToken ct;

public CancellationTokenExport (CancellationToken ct) : base(ct)
{
this.ct = ct;
ct.Register(() => OnCancellationRequested?.Invoke());
}
}
```

Bootsharp calls `Unwrap()` to obtain the value passed to C# — here a real `CancellationToken` backed by a source that's cancelled whenever the JavaScript token reports cancellation. The paired JavaScript class signals through the same surface:

```ts
import { CancellationToken } from "bootsharp";

const token = new CancellationToken();
token.cancel(); // fires onCancellationRequested
```

## Reference

The built-in specializations live in [`Specialized.cs`](https://github.com/elringus/bootsharp/blob/main/src/cs/Bootsharp.Common/Specialization/Specialized.cs); their JavaScript counterparts are the modules under [`src/js/src/bcl`](https://github.com/elringus/bootsharp/tree/main/src/js/src/bcl). Use them as a template when authoring your own.
50 changes: 47 additions & 3 deletions src/cs/Bootsharp.Common.Test/InstancesTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ private interface IBar;
private class Foo : IFoo;
private class Bar : IBar;
private class Proxy (int id) : JSProxy(id);
private class SpecializedExport (object it) : Bootsharp.SpecializedExport(it);

private class SpecializedImport (int id, object it) : Bootsharp.SpecializedImport(id)
{
protected internal override object Unwrap () => it;
}

private class DelegateProxy (int id) : JSProxy(id)
{
Expand Down Expand Up @@ -40,13 +46,25 @@ public void ShortCircuitsRegisteredExports ()
[Fact]
public void ShortCircuitsImportedProxies ()
{
Assert.Equal(42, Export(new Proxy(42)));
Assert.Equal(Export(new Proxy(1)), Export(new Proxy(1)));
}

[Fact]
public void ShortCircuitsSpecializedExportsWrappingImportedProxy ()
{
Assert.Equal(Export(new SpecializedExport(new Proxy(1))), Export(new SpecializedExport(new Proxy(1))));
}

[Fact]
public void GeneratesUniqueIdsForSpecializedExportsNotWrappingImportedProxy ()
{
Assert.NotEqual(Export(new SpecializedExport(new object())), Export(new SpecializedExport(new object())));
}

[Fact]
public void ShortCircuitsImportedDelegates ()
{
Assert.Equal(42, Export(new DelegateProxy(42).Invoke));
Assert.Equal(Export(new DelegateProxy(1).Invoke), Export(new DelegateProxy(1).Invoke));
}

[Fact]
Expand Down Expand Up @@ -95,6 +113,7 @@ public void ShortCircuitsRegisteredImportsUntilDisposed ()
DisposeImported(42);
// Now we exercise the factory and register the new instance as '42'.
Assert.NotSame(imported, Resolve<IBar>(42));
DisposeImported(42);
}

[Fact]
Expand All @@ -105,7 +124,32 @@ public void ShortCircuitsImportedExports ()
}

[Fact]
public void ImportsNullWhenInstanceIsZero ()
public void UnwrapsResolvedSpecializedImportsOfRefType ()
{
var imported = new Bar();
RegisterImport(typeof(IBar), id => new SpecializedImport(id, imported));
Assert.Same(imported, Resolve<IBar>(1));
DisposeImported(1);
}

[Fact]
public void UnwrapsResolvedSpecializedImportsOfValueType ()
{
var imported = DateTime.Now;
RegisterImport(typeof(DateTime), id => new SpecializedImport(id, imported));
Assert.Equal(imported, Resolve<DateTime>(1));
DisposeImported(1);
}

[Fact]
public void UnwrapsResolvedSpecializedExports ()
{
var exported = new Foo();
Assert.Same(exported, Resolve<IFoo>(Export(new SpecializedExport(exported))));
}

[Fact]
public void ResolvesNullWhenIdIsZero ()
{
Assert.Null(Resolve<object>(0));
}
Expand Down
14 changes: 13 additions & 1 deletion src/cs/Bootsharp.Common.Test/TypesTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace Bootsharp.Common.Test;

public class TypesTest
{
private class SpecializedImport (int id) : Bootsharp.SpecializedImport(id);
private readonly CustomAttributeData export = GetMockExportAttribute();
private readonly CustomAttributeData import = GetMockImportAttribute();

Expand All @@ -16,7 +17,11 @@ public void TypesAreAssigned ()
{
Assert.Equal([typeof(IBackend)], new ExportAttribute(typeof(IBackend)).Types);
Assert.Equal([typeof(IFrontend)], new ImportAttribute(typeof(IFrontend)).Types);
Assert.Equal("Space", (new PreferencesAttribute { Space = ["Space"] }).Space[0]);
Assert.Equal(typeof(IBackend), new SpecializeExportAttribute(typeof(IBackend)).Clr);
Assert.Equal(typeof(IFrontend), new SpecializeImportAttribute(typeof(IFrontend)).Clr);
Assert.Equal("JS", new SpecializeImportAttribute(typeof(IFrontend), JS: "JS").JS);
Assert.Equal("Decl", new SpecializeImportAttribute(typeof(IFrontend), Decl: "Decl").Decl);
Assert.Equal("Space", new PreferencesAttribute { Space = ["Space"] }.Space[0]);
}

[Fact]
Expand All @@ -35,6 +40,13 @@ public void ImportParametersEqualArguments ()
.Select(a => a.Value));
}

[Fact]
public void SpecializedImportUnwrapsToItself ()
{
var imported = new SpecializedImport(1);
Assert.Same(imported, imported.Unwrap());
}

private static CustomAttributeData GetMockExportAttribute () =>
typeof(TypesTest).Assembly.CustomAttributes
.First(a => a.AttributeType == typeof(ExportAttribute));
Expand Down
17 changes: 17 additions & 0 deletions src/cs/Bootsharp.Common/Attributes/SpecializeExportAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Bootsharp;

/// <summary>
/// Allows customizing the way Bootsharp treats specified CLR type on the export direction (C# -> JS).
/// When applied to a class inherited from <see cref="SpecializedExport"/>, Bootsharp wraps exported
/// instances of the specialized type into instances of this class before crossing the interop boundary.
/// The exported specialization is expected to be paired with the <see cref="SpecializeImportAttribute"/>
/// counterpart and contain implementations for all the abstract members defined on the imported specialization.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public sealed class SpecializeExportAttribute (Type clr) : Attribute
{
/// <summary>
/// The CLR type to specialize the export for.
/// </summary>
public Type Clr { get; } = clr;
}
25 changes: 25 additions & 0 deletions src/cs/Bootsharp.Common/Attributes/SpecializeImportAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Bootsharp;

/// <summary>
/// Allows customizing the way Bootsharp treats specified CLR type on the import direction (JS -> C#).
/// When applied to an abstract class inherited from <see cref="SpecializedImport"/>, Bootsharp will use
/// the class as the base for the generated interop proxy and treat the abstract members of the class as
/// the actual interop surface. All the abstract members are expected to be implemented on the paired export
/// specialization of the class annotated with <see cref="SpecializeExportAttribute"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public sealed class SpecializeImportAttribute (Type clr, string? JS = null, string? Decl = null) : Attribute
{
/// <summary>
/// The CLR type to specialize the import for.
/// </summary>
public Type Clr { get; } = clr;
/// <summary>
/// Raw snippet spliced into the generated JavaScript proxy class.
/// </summary>
public string? JS { get; } = JS;
/// <summary>
/// Raw snippet spliced into the generated TypeScript declaration.
/// </summary>
public string? Decl { get; } = Decl;
}
34 changes: 19 additions & 15 deletions src/cs/Bootsharp.Common/Interop/Instances.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,32 +31,36 @@ public static class Instances
/// Resolves a registered instance associated with the specified ID, or uses a factory that
/// was registered with <see cref="RegisterImport"/> to register a new imported instance.
/// </summary>
public static T Resolve<T> (int id) where T : class
public static T? Resolve<T> (int id)
{
if (id == 0) return null!;
if (id < 0) return (T)exportedById[id];
if (importedById.GetValueOrDefault(id) is { } weak) return (T)weak.Target!;
var instance = (T)importers[typeof(T)](id);
importedById[id] = new(instance);
return instance;
if (id == 0) return default;
if (id < 0) return UnwrapExport(exportedById[id]);
if (importedById.GetValueOrDefault(id) is { } weak) return UnwrapImport(weak.Target!);
var it = importers[typeof(T)](id);
importedById[id] = new(it);
return UnwrapImport(it);

static T UnwrapImport (object o) => o is T t ? t : (T)((SpecializedImport)o).Unwrap();
static T UnwrapExport (object o) => o is T t ? t : (T)((SpecializedExport)o)._it;
}

/// <summary>
/// Registers specified exported (C#) instance and returns the associated unique ID.
/// Short-circuits already registered exported and imported instances.
/// </summary>
/// <param name="instance">The instance to register.</param>
/// <param name="it">The instance to register.</param>
/// <param name="cb">Callback to invoke when registering and disposing the instance.</param>
/// <returns>Unique ID associated with the registered instance.</returns>
public static int Export<T> (T? instance, ExportCallback<T>? cb = null) where T : class
public static int Export<T> (T? it, ExportCallback<T>? cb = null) where T : class
{
if (instance is null) return 0;
if (instance is JSProxy imp) return imp._id;
if (instance is Delegate { Target: JSProxy del }) return del._id;
if (idByExported.TryGetValue(instance, out var id)) return id;
if (it is null) return 0;
if (it is JSProxy js) return js._id;
if (it is Delegate { Target: JSProxy del }) return del._id;
if (it is SpecializedExport { _it: JSProxy ejs }) return ejs._id;
if (idByExported.TryGetValue(it, out var id)) return id;
id = idPool.Count > 0 ? idPool.Dequeue() : nextId++;
exportedById[idByExported[instance] = id] = instance;
if (cb != null) onDisposeById[id] = cb(id, instance);
exportedById[idByExported[it] = id] = it;
if (cb != null) onDisposeById[id] = cb(id, it);
return id;
}

Expand Down
Loading
Loading