Kanject.Core.Channels 3.9.6

Prefix Reserved
dotnet add package Kanject.Core.Channels --version 3.9.6
                    
NuGet\Install-Package Kanject.Core.Channels -Version 3.9.6
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Kanject.Core.Channels" Version="3.9.6" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Kanject.Core.Channels" Version="3.9.6" />
                    
Directory.Packages.props
<PackageReference Include="Kanject.Core.Channels" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Kanject.Core.Channels --version 3.9.6
                    
#r "nuget: Kanject.Core.Channels, 3.9.6"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Kanject.Core.Channels@3.9.6
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Kanject.Core.Channels&version=3.9.6
                    
Install as a Cake Addin
#tool nuget:?package=Kanject.Core.Channels&version=3.9.6
                    
Install as a Cake Tool

Kanject Channels

One handler signature. Every hosting mode.

Write (ReadOnlyMemory<T> batch, CancellationToken ct) → ValueTask once, and run it unchanged under a long-lived BackgroundService, a Lambda invocation-local drainer, an SQS-triggered Lambda, a Kinesis stream, or an in-process smoke test. The transport is a composition-root concern — your domain code never references an adapter.

// Domain — transport-agnostic, hosting-agnostic
[Channel(Capacity = 5000, BatchSize = 25)]
public partial ValueTask HandleAsync(
    ReadOnlyMemory<OrderEvent> batch, CancellationToken ct);

// Composition root — pick the transport
services
    .AddOrderHandlerHandleChannel()            // domain wiring
    .AddSqsChannelTransport("orders-queue");   // adapter wiring (separate package)

The same handler moves between hosts as your deployment model evolves — no rewrites, no framework lock-in, no Lambda-SDK dependency in your domain assembly.

Status: Phase 1.3 shipped. Cancellation, ordering, scope-boundary and FullMode contracts are locked (see ChannelOptions). SQS adapter + generator shipped (Phase 2.1 / 2.2). Remaining phases: partial-batch atomicity, Kinesis adapter, samples, integration tests.


What you get

Hosting-mode portability One handler shape runs under BackgroundService, Lambda invocation-local, or SQS-triggered Lambda without edits.
Hexagonal seam Generated IXChannelWriter port lives in your domain assembly — zero adapter dependencies.
Adapter-agnostic SQS, Kinesis, EventBridge, in-memory all plug at the composition root; each lives in its own package with its own generator.
Lambda-first deadline handling 3-layer defence (soft CTS → un-acked-ID projection → SQS visibility redelivery) baked in.
Conformance-gated parity Every adapter passes the shared ChannelConformanceTests suite before release.
Observability PrintInConsole log records + pluggable IChannelMetrics sink (CloudWatch / OTel / Meter).
Source-generated Typed writer, consumer, DI helper, and adapter-specific entry-points — no reflection, AOT-clean.
Internal re-batching SQS hands you 100 records? Handle in batches of 25 without leaving the invocation.

🏛️ Hexagonal positioning

  • Port: The generated IXChannelWriter interface is the driven port. It lives in the domain assembly, with no dependency on any adapter or SDK.
  • Adapters: Each transport (SQS, EventBridge, Kinesis, in-memory, etc.) is a separate package, providing a provider-specific DI extension and generator. No domain code references any adapter directly.
  • Composition root: The adapter is selected at startup via DI wiring, not via attribute arguments.

Conformance contract: Every adapter must pass the universal ChannelConformanceTests suite (see below) to guarantee observable behavior is uniform across transports.


🧭 Parallel vs Channels — which do I reach for?

Parallel and Channels solve adjacent problems and are routinely composed. If you only need one, pick the simplest that fits.

Scenario Reach for
Materialized collection, parallel batched I/O, one host ParallelForEachChunkAsync is simpler and cheaper.
One-shot SQS batch processing inside a single Lambda invocation Parallel — no channel needed.
Async producer (pagination, stream, webhook fan-in) → batch consumer Channels (optionally × Parallel inside the handler).
The same handler must run unchanged as BackgroundService, Lambda inline, and SQS-triggered Lambda Channels — handler portability is the unique value.
Time-based flush of slow-arriving items (e.g. "flush every 200 ms or every 25 items") ChannelsFlushInterval is built-in.
Backpressure between a fast producer and a bounded, slower consumer ChannelsFullMode.Wait gives you that for free.
You want per-item retry with backoff on a materialized list ParallelRetryCount/RetryBackoffMs.

Rule of thumb: if the production side is synchronous and bounded, Parallel wins on ceremony; if the production side is asynchronous or unbounded, or if you need the handler to be host-portable, Channels earns its keep.


🧭 SQS Channels adapter vs native SQS QueueConsumer — which do I reach for?

Two SQS packages exist; they solve overlapping-but-distinct problems. Pick by the shape of the thing you're building.

Package Scope Shape
Kanject.Core.Channels.Provider.AwsSqs Receive-only, Lambda-first. A bounded-channel drainer + SQSBatchResponse projection. No producer, no registry, no schema. One-function Lambda entry-point; the same handler also runs under BackgroundService or invocation-local.
Kanject.Core.Queue.Provider.AwsSqs Full messaging framework. Producer (IQueueManager), receiver (AbstractQueueConsumer<T>, [QueueConsumer], [RouteQueueConsumer]), queue registry, schema/doc generation, publish/subscribe wiring. Long-running service that both sends and receives; batch DLQ; routed consumers.
Scenario Reach for
SQS-triggered Lambda, partial-batch failures, deadline-aware drain, minimal ceremony Channels SQS adapter[SqsChannel] emits the entry-point; no DI registry required.
Same handler must run under BackgroundService, invocation-local, and SQS-triggered Lambda Channels SQS adapter — handler portability is the unique value.
You need retry-with-backoff inside a Lambda invocation before surfacing failures to SQS Channels SQS adapterRetryCount / RetryBackoff in ChannelOptions.
Long-running worker that polls queues, publishes to other queues, subscribes to SNS topics, routes by payload schema Native QueueConsumerIQueueManager, [RouteQueueConsumer], SubscribeToQueue are built for this.
You rely on the queue registry (auto-create, namespace prefixing, declarative provisioning) Native QueueConsumer — the Channels adapter is agnostic to how the queue was provisioned.
You publish typed messages with [QueueMessage] / [EventQueueMessage] and want the generated producer Native QueueConsumer — Channels SQS adapter is receive-only.
You want both: a legacy BackgroundService and a new Lambda entry-point on the same handler Channels SQS adapter — the native one is coupled to BackgroundService; Channels is mode-portable.

Rule of thumb: if you need to send, or you want the framework to own queue lifecycle and routing, use the native provider. If you only need to receive a batch inside a Lambda (or move an existing handler between hosting modes), use the Channels adapter. The two can coexist in one service — e.g. native IQueueManager on the send side, Channels [SqsChannel] on the Lambda ingress.


📜 Contracts at a glance

These are load-bearing semantics. Authoritative versions live in the ChannelOptions XML docs; the summary here is for orientation.

Cancellation is all-or-nothing within a batch. The handler receives a CancellationToken for cooperative shutdown. If the handler observes the token and returns early — whether by throwing OperationCanceledException or returning normally — the entire batch is treated as un-acked and its message IDs flow into BatchItemFailures. The drainer does not support partial-ack within a batch. Handlers that need per-item ack semantics must set BatchSize = 1. Cancellation is checked between reads (per-item) or between batches (batched) — never torn across a dispatch.

Scope boundary per mode.

Mode DI scope lifetime
Hosted (BackgroundService) one scope per batch dispatch
Invocation-local (Lambda inline) one scope per invocation
Distributed (SQS-triggered Lambda) one scope per invocation — adapter-defined

Ordering guarantees.

Mode Within a batch Across batches
Hosted, ConsumerConcurrency = 1 FIFO FIFO
Hosted, ConsumerConcurrency > 1 FIFO No guarantee
Invocation-local FIFO FIFO
Distributed + SQS Standard FIFO (consumer-local) No guarantee
Distributed + SQS FIFO FIFO FIFO per MessageGroupId
Distributed + Kinesis FIFO per shard FIFO per shard
Distributed + EventBridge No guarantee No guarantee

FullMode × mode validity. BoundedChannelFullMode.Wait is rejected at startup for invocation-local drains (would block past the Lambda deadline). Use DropOldest, DropNewest, or DropWrite for invocation-local; the SQS adapter forces DropWrite since capacity matches record count and the channel cannot overflow.


⚠️ The Problem

You have a stream of items and a batched, async per-batch handler. The naïve options:

  1. Hand-rolled Channel<T> + BackgroundService — every team rewrites the same drainer, batching, restart-on-fault, graceful-shutdown, and DI-scope code, slightly differently, slightly buggily. ~80 lines of plumbing per channel.
  2. SQS poll loop in a hosted service — couples your handler to AWS SDK; no in-proc fast path; no batching layer between the SDK's per-poll batch and your business batch size; no graceful-shutdown story.
  3. Generic IBackgroundJobQueue abstractions — usually Task-typed, lose item-type fidelity, can't batch, can't surface backpressure.
  4. Lambda + SQS event source — works, but you get whatever batch SQS hands you (max 10 by default; up to 10000 with MaximumBatchingWindowInSeconds), no internal re-batching, no in-invocation fan-out, no DI-scope-per-batch.

What's missing is the same trick Parallel pulled off: declare the channel intent on the method, get a typed producer + a host-appropriate consumer for free.

🛠️ The Solution

Five pieces, mirroring the Parallel quintet:

Piece Status Purpose
Engine (ChannelDispatcher, InvocationLocalDrainer) ⏳ Phase 1.0 Bounded-channel drainer with batching, backoff-restart, graceful drain, deadline-aware cancellation
Attributes ([Channel], [ChannelProducer]) ⏳ Phase 1.0 Compile-time defaults the generator surfaces as method-local config
Source generator (ChannelGenerator) ⏳ Phase 1.0 Emits typed writer interface, consumer, DI helper
Analyzer (ChannelMethodAnalyzer) ⏳ Phase 1.0 KANCHN001–015 compile-time validation
Transport seam (IChannelProducerTransport + IChannelConsumerTransport) ⏳ Phase 2.0 Pluggable distributed mode — AwsSqs, EventBridge, Kinesis providers

The engine is fully usable on its own — call ChannelDispatcher.RunAsync(...) directly with your handler. The generator is what removes the call-site boilerplate.


📖 Attribute Surface

[AttributeUsage(AttributeTargets.Method)]
public sealed class ChannelAttribute : Attribute
{
    // ── Channel sizing ────────────────────────────────────────────
    public int Capacity { get; init; } = 1024;
    public BoundedChannelFullMode FullMode { get; init; } = BoundedChannelFullMode.Wait;

    // ── Batching ──────────────────────────────────────────────────
    public int BatchSize       { get; init; } = 1;     // 1 = no batching
    public int FlushIntervalMs { get; init; } = 0;     // 0 = no time-flush

    // ── Hosting ───────────────────────────────────────────────────
    public ChannelMode Mode { get; init; } = ChannelMode.Default;
    public ServiceLifetime Lifetime { get; init; } = ServiceLifetime.Scoped;

    // ── Resilience ────────────────────────────────────────────────
    public bool RestartOnFault     { get; init; } = true;
    public int  MaxRestartAttempts { get; init; } = -1;        // -1 = infinite
    public int  SafetyMarginMs     { get; init; } = 500;       // InvocationLocal only

    // ── Naming ────────────────────────────────────────────────────
    public string? ChannelName { get; init; }
}

public enum ChannelMode
{
    Default,           // generator picks: InvocationLocal if Lambda referenced, else InProcess
    InProcess,         // System.Threading.Channels + BackgroundService consumer
    InvocationLocal,   // System.Threading.Channels + inline drainer (Lambda)
    Distributed        // Adapter-provided; consumer is a separate process/Lambda
}

Producer-only declaration (when the producer doesn't itself host a [Channel] method):

[ChannelProducer(typeof(OrderEvent))]
public partial class OrderProducer { }
// → generates IOrderEventChannelWriter + DI helper

🧱 Hosting Modes & Adapter Selection

Mode Producer Consumer When to pick
InProcess IXChannelWriter (DI) Generated BackgroundService Long-running container / EC2 / hosted worker
InvocationLocal IXChannelWriter (DI) Generated inline drainer the entry-point awaits Lambda — in-invocation fan-out (e.g. SQS hands you 100 records, you internally batch to 25)
Distributed IXChannelWriter (DI) Adapter-provided consumer (e.g. SQS Lambda, EventBridge rule) Cross-process / cross-invocation
Default (one of the above) (one of the above) Generator picks InvocationLocal if the project references Kanject.Core.CloudFunction.Provider.AwsLambda, else InProcess. Compile-time only — no reflection.

Adapter selection is at the composition root:

// Domain (annotated method) — transport-agnostic
[Channel(Capacity = 5000, BatchSize = 25)]
public partial ValueTask HandleBatchAsync(ReadOnlyMemory<OrderEvent> batch, CancellationToken ct);

// Composition root — adapter chosen here
services
    .AddOrderHandlerHandleBatchChannel()                  // domain wiring
    .AddSqsChannelTransport("orders-queue");              // adapter wiring (separate package)

🚀 Generator Topology

Two generators, two packages:

Generator Lives in Outputs
ChannelDomainGenerator Kanject.Core.Annotations Port interface, handler interface fragment, in-process drainer/hosted service, DI helper (transport-agnostic)
ChannelTransportBindingGenerator Adapter package (e.g. Kanject.Core.Channel.Provider.AwsSqs.Annotations) Adapter-specific DI extension, provider-specific analyzers, conformance test hooks

For:

public sealed partial class OrderHandler
{
    [Channel(Capacity = 5000, BatchSize = 25, FlushIntervalMs = 200)]
    public partial ValueTask HandleBatchAsync(
        ReadOnlyMemory<OrderEvent> batch, CancellationToken ct);
}

…the generator emits five outputs (mirrors the Parallel generator's split):

// 1. Producer interface — what callers inject
public interface IOrderHandlerHandleBatchChannelWriter
{
    ValueTask WriteAsync(OrderEvent item, CancellationToken ct = default);
    ValueTask WriteAsync(ReadOnlyMemory<OrderEvent> items, CancellationToken ct = default);
    bool TryWrite(OrderEvent item);
    ValueTask CompleteAsync();      // graceful drain signal
}

// 2. Auto-attached interface fragment on the consumer type
//    (the same partial-class trick KANPAR011 enforces)
partial class OrderHandler : IOrderHandlerHandleBatchChannelHandler { }

// 3. DI helper
public static IServiceCollection AddOrderHandlerHandleBatchChannel(
    this IServiceCollection services,
    Action<ChannelOptions>? configure = null);

// 4. Hosted consumer (Mode = InProcess)
internal sealed class OrderHandlerHandleBatchChannelService : BackgroundService { ... }

// 5. Invocation-local drainer (Mode = InvocationLocal)
public static class OrderHandlerHandleBatchChannelDrain
{
    public static Task<ChannelDrainResult> DrainAsync(
        IServiceProvider sp, CancellationToken ct);
}

ChannelDrainResult exposes:

  • int ProcessedCount
  • int FailedCount
  • IReadOnlyList<string> UnAckedMessageIds — wire straight into SQSBatchResponse.BatchItemFailures

🔌 Lambda Integration (Mode = InvocationLocal)

The kanject-specific value-add: internal re-batching across the records SQS hands a single Lambda invocation, deadline-aware, with partial-batch-failure semantics.

// [CloudFunction] handler — kanject's existing Lambda primitive
public async Task<SQSBatchResponse> HandleAsync(SQSEvent evt, ILambdaContext ctx)
{
    using var scope = _sp.CreateAsyncScope();
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(default);
    cts.CancelAfter(ctx.RemainingTime - TimeSpan.FromMilliseconds(500));

    var writer = scope.ServiceProvider
        .GetRequiredService<IOrderHandlerHandleBatchChannelWriter>();

    foreach (var record in evt.Records)
        await writer.WriteAsync(
            JsonSerializer.Deserialize<OrderEvent>(record.Body)!, cts.Token);
    await writer.CompleteAsync();

    var drain = await OrderHandlerHandleBatchChannelDrain.DrainAsync(
        scope.ServiceProvider, cts.Token);

    return new SQSBatchResponse
    {
        BatchItemFailures = drain.UnAckedMessageIds
            .Select(id => new SQSBatchResponse.BatchItemFailure { ItemIdentifier = id })
            .ToList(),
    };
}

Lambda timeout behaviour

Three layers of defence, all compile-time wired:

Layer 1 — Soft deadline. Generated glue creates a linked CTS firing RemainingTime − SafetyMarginMs before the hard kill. Drainer observes the token at:

  • WaitToReadAsync (no new batch starts)
  • the handler(batch, ct) boundary (handler gets a cooperative-cancel chance)
  • between batches, never mid-batch — a batch is either fully executed or fully un-acked.

Layer 2 — Partial-batch failures. Un-handled items go into BatchItemFailures so SQS re-delivers only the tail. Successfully handled messages are gone for good. Requires ReportBatchItemFailures on the event source — KANCHN016 (project-level analyzer) warns if missing.

Layer 3 — Hard kill. If the safety margin was wrong, Lambda kills the process; un-acked items are redelivered after the visibility timeout. At-least-once preserved at the cost of duplicates — this is the seam where future [Idempotent] plugs in.

Scenario Outcome Lost Duplicates
Soft-deadline, drainer cooperates Partial ack None None
Handler throws on a batch That batch's IDs in BatchItemFailures None The failed batch
Handler ignores CT, hard-kill Lambda returns failure None All un-acked
Mode = InvocationLocal + DropOldest overflow Producer drops silently Yes None — see KANCHN013

🔍 Diagnostics — KANCHN001…KANCHN016

ID Severity Trigger
KANCHN001 Error [Channel] method must be partial
KANCHN002 Error Containing type must be partial
KANCHN003 Error Return type must be Task / ValueTask
KANCHN004 Error First parameter must be T, ReadOnlyMemory<T>, or IReadOnlyList<T>
KANCHN005 Error BatchSize > 1 requires ReadOnlyMemory<T> / IReadOnlyList<T>
KANCHN006 Warning BatchSize == 1 with ReadOnlyMemory<T> arg (probably unintended)
KANCHN007 Error Capacity must be > 0
KANCHN008 Error Mode = Distributed requires non-null Transport
KANCHN009 Error Transport name does not resolve to a registered IChannelProducerTransport / IChannelConsumerTransport
KANCHN010 Warning Mode = InvocationLocal on a project with no Lambda reference
KANCHN011 Error [Channel] and [Parallel] both applied — pick one (lifts in Phase 3.5)
KANCHN012 Error Item type T must be JSON-serializable for Distributed
KANCHN013 Warning FullMode = DropOldest / DropWrite without ack semantics — silent loss
KANCHN014 Error MaxRestartAttempts must be -1 or > 0
KANCHN015 Warning Method is static — no IXChannelHandler interface fragment generated
KANCHN016 Warning Mode = InvocationLocal requires ReportBatchItemFailures on the SQS event source (project-level heuristic via serverless.yml / template.yaml if discoverable)

🔌 Adapter seam: port/adapters split

Producer/consumer transport interfaces are split:

public interface IChannelProducerTransport
{
    string Name { get; }
    ValueTask SendAsync<T>(string resource, T item, CancellationToken ct);
    // Provider-specific batch methods may be exposed via type-test (see README notes)
}

public interface IChannelConsumerTransport
{
    string Name { get; }
    ChannelReader<T> Subscribe<T>(string resource, ChannelSubscriptionOptions options);
}

Each adapter package provides its own generator and analyzer.

Conformance contract: Every adapter must pass the universal ChannelConformanceTests suite:

Test What it asserts
Single_message_round_trips Producer write → consumer reads exactly once
Batch_of_N_round_trips_in_order FIFO providers preserve order; non-FIFO documents the relaxation
Cancellation_during_drain_yields_unacked_ids The Lambda-timeout contract is universal
Producer_overflow_honors_FullMode Wait blocks; DropOldest drops the oldest; etc. — provider-uniform
Handler_throw_yields_partial_batch_failure Same ChannelDrainResult shape from every transport
Item_size_at_provider_limit_succeeds Provider-specific cap (SQS 256KB) is enforced via analyzer + runtime guard
Item_size_above_limit_fails_at_send Loud failure, not silent truncation

Every provider project must subclass the conformance suite and pass all tests before publishing.


🗺️ Roadmap

⏳ Phase 1.0 — InProcess mode end-to-end

  • ChannelAttribute, ChannelProducerAttribute, ChannelMode enum
  • ChannelDispatcher engine
  • ChannelGenerator (5 outputs)
  • ChannelMethodAnalyzer (KANCHN001–007, 011, 014, 015)
  • Sample console fan-out app

⏳ Phase 1.5 — InvocationLocal drainer

  • InvocationLocalDrainer engine
  • KANCHN010 wired
  • Default-mode generator-time heuristic via Compilation.ReferencedAssemblyNames
  • Lambda + SQS sample

⏳ Phase 2.0 — Distributed transport seam + AwsSqs adapter package

  • IChannelProducerTransport / IChannelConsumerTransport interfaces
  • Kanject.Core.Channel.Provider.AwsSqs (adapter package)
  • ChannelTransportBindingGenerator emits DI extension, provider-specific analyzers, conformance suite

⏳ Phase 2.5 — EventBridge adapter package

  • Kanject.Core.Channel.Provider.EventBridge (adapter package)
  • EventBridge-rule → SQS-pipe → consumer-Lambda end-to-end sample

⏳ Phase 3.0 — [CircuitBreaker] integration

  • Open breaker pauses drain; surfaces via OnOpen callback

⏳ Phase 3.5 — [Channel] × [Parallel] composition (lifts KANCHN011)

  • Each batch dispatched via ParallelLoop.ForEachChunkAsync
  • Recurring × Parallel × Channels triple-composition sample

⏳ Phase 4.0 — Kinesis provider

  • Ordered, sharded distributed transport

✏️ Design decisions worth knowing

  • Default mode is generator-time, not runtime. The generator inspects the project's referenced assemblies (Compilation.ReferencedAssemblyNames) — no reflection at runtime, no startup cost, no surprise behaviour swaps when a dependency is added at deploy time.
  • Producer/consumer transports are split. SQS-receive does its work via host wiring (event source mapping), not application code; a unified IChannelTransport would force half-empty Subscribe implementations. The split mirrors how AWS actually works, and matches the port/adapter boundary in hexagonal.
  • Adapter selection is at the composition root. The domain never names its adapter; the handler is annotated transport-agnostically, and the adapter is chosen in DI wiring. This keeps the domain assembly free of SDK references and enables true swap-ability.
  • Conformance contract is enforced. Every adapter must subclass and pass the universal conformance suite before publishing, ensuring observable behavior is uniform.
  • Generator topology is two-tier. The domain generator emits only transport-agnostic code; each adapter package ships its own generator and analyzer for provider-specific wiring and diagnostics.
  • Adapter scorecard is published. Each adapter's cold-start, steady-state allocation, and throughput are benchmarked and published in the README; regressions block release.
  • Soft-deadline cancellation respects batch boundaries. A batch is either fully executed or fully un-acked; cancellation never tears mid-batch. This is what makes partial-batch-failure ack semantics safe to rely on.
  • FullMode = DropOldest / DropWrite warns by default. Silent message loss is rarely what callers actually want; KANCHN013 surfaces it loudly.
  • Predicate parity from day one. ChannelMethodAnalyzer and ChannelGenerator share a single ChannelTransformHelper for ForAttributeWithMetadataName extraction — no repeat of the schema-vs-code-generator divergence the repo memory documents for the Dynamo pipeline.
  • Partial enforcement ships in Phase 1.0. No KANPAR011-style after-the-fact retrofit; KANCHN001 + KANCHN002 are in the v1 diagnostic set.
  • Test fixtures partial from the first commit. Every [Channel]-annotated test type is public sealed partial class — the Parallel test-suite migration is not repeated here.
  • EmitCompilerGeneratedFiles trap pre-documented in the sample csproj. Same MSBuild comment block as the Parallel sample.
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.9.6 48 7/18/2026
3.9.5 110 7/13/2026
3.9.4 89 7/11/2026
3.9.3 88 7/11/2026
3.9.2 100 7/9/2026