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ędzieMatrixHttpClient.PostAsync<T>(url, body, ct)z generykiemDictionary<string, object>w polach event content (bo Matrix events są polimorficzne)[MaxLength(255)]zSystem.ComponentModel.DataAnnotationsna 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 → Promiseglue, mapa handle'i) - API w stylu fluent — konsument dziedziczy
IdbDatabasei 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 są [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