loading
Note:
A port is a private conversation Where BroadcastChannel shouts to everyone on the origin, a channel is between exactly two holders. A port belongs to one context at a time, so giving one away transfers it and this side stops being able to use it - that is the guarantee, not a limitation to work around.

Support check

IsSupported

Supported by every current engine. During prerender/SSR the check returns false rather than throwing, so defer it to OnAfterRenderAsync.

C#
@inject Bit.Butil.MessageChannel messageChannel

var supported = await messageChannel.IsSupported();
Live sample
support check output
Results will appear here when you interact with the samples.

Two ends, both symmetric

Create / OnMessage / Start / PostMessage

Neither port is privileged - there is no client end and no server end. Both are wired up here so the traffic is visible in both directions; in a real use one of them would have been sent somewhere else. Messages are JSON: what survives is what System.Text.Json can write and JSON.parse can read.

C#
var channel = await messageChannel.Create();

await channel!.Port1.OnMessage(m => InvokeAsync(StateHasChanged));
await channel.Port2.OnMessage(m => InvokeAsync(StateHasChanged));
await channel.Port1.Start();
await channel.Port2.Start();

await channel.Port1.PostMessage(new { from = "port1", text = "hello" });
Live sample
Channel None created.
channel output
Results will appear here when you interact with the samples.

Nothing is lost before Start

Start

A port queues everything it receives until it is started - which is what makes 'create it, hand it over, listen, then open it' work, and why listening does not start the port for you. The flip side: a port that is never started queues for as long as it lives, which is a slow leak rather than an error.

C#
var channel = await messageChannel.Create();
await channel!.Port2.OnMessage(m => { /* not called yet */ });

await channel.Port1.PostMessage(new { queued = 1 });
await channel.Port1.PostMessage(new { queued = 2 });

// both arrive now, in order
await channel.Port2.Start();
Live sample
queueing output
Results will appear here when you interact with the samples.

Binary, without a copy

PostBytes

Transferring moves the underlying buffer to the receiver rather than copying it. The .NET array you pass in is a separate copy already and is unaffected; what gets detached is the JavaScript-side buffer.

C#
await channel!.Port1.PostBytes([1, 2, 3, 4], transfer: true);

// arrives as: m.IsBinary == true, m.Data == [1, 2, 3, 4]
Live sample
binary output
Results will appear here when you interact with the samples.

Sending a port over a port

PostWithPorts

A port can carry other ports, which is how a third party is introduced to a conversation it was not part of - hand it one end of a channel it did not create. The ports sent are transferred, so the handles passed here stop working and the receiver owns them.

C#
var introduction = await messageChannel.Create();

// Port2 of the new channel travels over the existing channel
await channel!.Port1.PostWithPorts(new { op = "here is a line" }, [introduction!.Port2]);

// introduction.Port2 no longer works here; whoever received it owns it now
Live sample
transfer output
Results will appear here when you interact with the samples.
Warning:
Close what you open An unclosed port keeps its queue and its listeners alive for as long as the page lives. Dispose the channel handle, which closes and releases both ends - as a safety net, the MessageChannel service does the same for anything left behind when its scope is torn down. A port that was transferred away is no longer yours to close, and releasing it here is a no-op rather than an error.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes MessageChannel. Returns default (false) during prerender/SSR instead of throwing.
Create
ValueTask<MessageChannelHandle?> Create()
Creates a channel and its two ports. Null when the runtime has no MessageChannel.
DisposeAsync
ValueTask DisposeAsync()
On scope/circuit teardown, closes and releases every port and channel whose handle was never disposed.
MessageChannelHandle.Port1 / Port2
MessagePortHandle Port1, Port2
The two ends. Symmetric - neither is privileged.
MessagePortHandle.OnMessage
ValueTask<ButilSubscription?> OnMessage(Action<ButilMessage> onMessage)
Runs the callback for every message. Does not start the port. Several listeners can share one port.
MessagePortHandle.Start
ValueTask Start()
Begins delivery. Everything queued while the port was closed arrives now, in order. Idempotent.
MessagePortHandle.PostMessage
ValueTask<bool> PostMessage<T>(T value, JsonSerializerOptions? options = null)
Posts a message as JSON. False when the port has been released, closed or transferred away.
MessagePortHandle.PostBytes
ValueTask<bool> PostBytes(byte[] data, bool transfer = true)
Posts raw bytes, moving the buffer rather than copying it when transfer is true.
MessagePortHandle.PostWithPorts
ValueTask<bool> PostWithPorts<T>(T value, MessagePortHandle[] ports, JsonSerializerOptions? options = null)
Posts a message carrying other ports. They are transferred, so the handles passed here stop working.
MessagePortHandle.Close
ValueTask Close()
Closes the port. Nothing more is delivered or sent.
ButilMessage
record (bool IsBinary, string? Json, byte[]? Data) { T? Deserialize<T>() }
One message. Binary payloads stay binary; everything else is JSON - always valid JSON, so a plain string arrives quoted.
An unhandled error has occurred. Reload 🗙