netrix to mój prywatny SDK do Matrixa w .NET — używałem go długo dla bota i homelabu, ale chciałem go też uruchomić w Blazor WebAssembly żeby strona, którą czytasz, mogła otworzyć szyfrowany DM bez backendu. To wymagało AOT.

Tu zapisuję, co mnie po drodze ugryzło.

Punkt wyjścia

netrix był pisany w stylu, który dla "normalnego" .NET-a jest komfortowy:

  • JsonSerializer.Deserialize<T>(json) wszędzie
  • MatrixHttpClient.PostAsync<T>(url, body, ct) z generykiem
  • Dictionary<string, object> w polach event content (bo Matrix events są polimorficzne)
  • [MaxLength(255)] z System.ComponentModel.DataAnnotations na właściwościach modeli (dla EF Core)

Pierwsze dotnet publish -p:PublishAot=true -r linux-x64 wypluło kilkadziesiąt IL2026 / IL3050. Każdy z nich to deklaracja "ta metoda używa refleksji, w trim-mode pęknie".

Migracja JSON

Najwięcej roboty była zamiana JsonSerializer.Deserialize<T> na wariant z JsonTypeInfo<T>:

// było
var resp = JsonSerializer.Deserialize<UploadKeysResponse>(body);

// jest
var resp = JsonSerializer.Deserialize(body, NetrixJsonContext.Default.UploadKeysResponse);

NetrixJsonContext to partial class : JsonSerializerContext z setką [JsonSerializable]-i. Source generator robi resztę. Zero refleksji w runtime.

Dla MatrixHttpClient to znaczyło dorzucenie nowych przeciążeń:

public async Task<TResp?> PostAsync<TReq, TResp>(
    string url,
    TReq body,
    JsonTypeInfo<TReq> reqTypeInfo,
    JsonTypeInfo<TResp> respTypeInfo,
    CancellationToken ct = default) { /* ... */ }

i przejechanie po wszystkich call sites — w Netrix.Client, Netrix.Client.Encryption, testach. Łącznie 25+ miejsc tylko w Encryption.

Pułapka: anonimowe typy w body request (new { device_keys = ... }) nie da się zarejestrować w JsonContext. Trzeba je zamienić na strongly-typed records:

// nie kompiluje pod AOT
await http.PostAsync(url, new { ... }, ct);

// OK
internal sealed record EmptyRequest { public static EmptyRequest Instance { get; } = new(); }
await http.PostAsync(url, EmptyRequest.Instance, ctx.EmptyRequest, ctx.EmptyResponse, ct);

Polimorficzne event content

Matrix events mają pole content które jest cokolwiek. W netrix to było Dictionary<string, object>, co STJ source-gen kompletnie odrzuca pod AOT (nie wie, jak deserializować object).

Rozwiązanie: zamiast słownika trzymamy JsonElement. Dalej bez schemy, ale serializator wie jak go zapisać/odczytać bez refleksji:

public sealed class RoomEventChunk
{
    public required string Type { get; init; }
    public required JsonElement Content { get; init; }   // <- było Dictionary<string, object>
}

Konsumenci, którzy chcieli content["body"], dostają nową metodę IMessageContent.ToJsonNode() zwracającą JsonNode (gdy chcą edytować) albo bezpośrednio JsonElement.GetProperty("body").

[MaxLength] to też refleksja

To mnie zaskoczyło. MaxLengthAttribute w System.ComponentModel.DataAnnotations w konstruktorze ma RequiresUnreferencedCode — żeby walidator EF Core mógł sprawdzić Count na dowolnym typie. Z perspektywy AOT-trim: niesafe.

Migrowałem wszystkie [MaxLength] z modeli OLM na fluent EF Core w OnModelCreating:

modelBuilder.Entity<DeviceKey>(b =>
{
    b.Property(x => x.UserId).HasMaxLength(255).IsRequired();
    b.Property(x => x.DeviceId).HasMaxLength(64).IsRequired();
});

EF Core dalej wie limity, atrybuty zostały zwalone w pył. -28 IL2026 warningów.

IndexedDB w przeglądarce, w czystym C#

Drugi cel migracji: gdy netrix już buildował się AOT-clean, mogłem go ruszyć w Blazor WASM. Ale do tego potrzebowałem persystencji w przeglądarce dla:

  • sesji Matrix (access_token, device_id)
  • kluczy Olm/Megolm (zaserializowanych do nieprzezroczystych blobów przez sam Olm — już zaszyfrowane)
  • sync token, room state cache

Naturalnym targetem jest IndexedDB. Po obadaniu landscape'u (SqliteWasmHelper archived, Bit.Besql — EF Core w AOT to wciąż experimental, wszystkie istniejące IDB wrappery dla Blazor są albo dead, albo pre-1.0 hobby), uznałem że dla keystore'u E2EE najczystszy ruch to napisać własną cienką warstwę nad IDB przez [JSImport].

Tak powstał Netrix.Persistence.IndexedDb:

  • ~150 LOC JavaScript shim (jedyny JS — IDBRequest → Promise glue, mapa handle'i)
  • API w stylu fluent — konsument dziedziczy IdbDatabase i deklaruje typowane object stores:
public sealed class NetrixClientEncryptionDb : IdbDatabase
{
    public IdbObjectStore<string, OlmSession> OlmSessions { get; }
    public IdbObjectStore<(string Room, string Session), MegolmSession> MegolmSessions { get; }

    public NetrixClientEncryptionDb(IIdbConnection conn, ILoggerFactory lf)
        : base(conn, lf, name: "netrix-client-encryption", version: 1)
    {
        OlmSessions = DefineStore("olm_sessions", JsonCtx.Default.OlmSession,
            v => v.SessionId,
            cfg => cfg.WithIndex("by_remote", v => $"{v.OurDeviceId}|{v.RemoteUserId}"));
        MegolmSessions = DefineStore("megolm_sessions", JsonCtx.Default.MegolmSession,
            v => (v.RoomId, v.SessionId),
            cfg => cfg.WithIndex("by_room", v => v.RoomId));
    }
}

IIdbConnection jest internal — testowane przez NSubstitute. Encryption stores (8 łącznie: 3 client + 5 OLM) implementowane TDD (146 unit testów). Cały pipeline AOT-clean.

Pułapka 1: IndexedDB cursor na indeksie ignoruje wartość — tylko zmienia kolejność iteracji. Trzeba defensywnie filtrować w C#. Inaczej RemoveAllSessionsAsync(userId) skasowałoby ci całą bazę. Test był słaby; wzmocniłem (mock zwraca dwie sesje matching userId + jedną innego usera, asercja: tylko właściwe są usuwane).

Pułapka 2: [JSImport] source-generator emituje unsafe kod — czyli <AllowUnsafeBlocks>true</AllowUnsafeBlocks> w csproj jest obowiązkowe. Plus JSHost/JSObject[SupportedOSPlatform("browser")] → CA1416 dla każdego callera spoza net10.0-browser. Najczystsze: [SupportedOSPlatform("browser")] na klasę, propaguje na metody.

Pułapka 3: KeyEncoder wspiera ValueTuple natywnie ((string, string) jako compound key serializowany do ["a","b"]), ale rekordy wymagają refleksji nad properties. Ten path jest oznaczony [RequiresUnreferencedCode] z notką "dla pełnego AOT używaj ValueTuple". W praktyce — w 95% przypadków ValueTuple wystarcza.

Co dalej

Jest jeszcze trochę długu — MatrixClientBuilder.WithEncryption() był latami stub flag-setterem (komentarze // Services.AddNetrixOlm()). Ja w tej sesji przeniosłem go do osobnej extension w Netrix.Client.Encryption.Extensions, która faktycznie wywołuje services.AddMatrixEncryption() — wszystkie real OLM/Megolm managery są teraz zarejestrowane. Test asertuje ICryptoAccountManager faktycznie się resolve'uje.

Wciąż zostaje nieukończony AOT migration debt w Netrix.Client.Integration.Tests (27 errorów, ten sam pattern co Encryption). Następna sesja.

Drobny smaczek: ta strona, którą czytasz, używa już persistence-indexeddb. Czat AI (po lewej w /chat/) jest TF-IDF nad treścią — w tym ten post. Po dodaniu go do indeksu, zapytanie "AOT" powinno trafiać tutaj.

— Jurek