WindowMessaging
window.postMessage: talking to an embedded iframe, the page that embedded you, or a window you opened - across origins, where nothing else can reach.
@inject Bit.Butil.WindowMessaging windowMessagingMDN reference
Origin - which the browser sets and nobody can forge. So
pass the origins you trust to Listen, and name the origin you expect when sending
rather than "*": a frame may not still contain the document you loaded into it.
window.postMessage has existed since forever; the check is here so this API has the same shape as the rest. During prerender/SSR it returns false rather than throwing.
@inject Bit.Butil.WindowMessaging windowMessaging
var supported = await windowMessaging.IsSupported();The document in the frame below is a real separate document, so what crosses between them are real cross-document messages. A target is a description rather than a reference: the window behind an iframe is replaced on every navigation, so it is looked up on each send - which is also why a message sent before the frame has loaded goes nowhere rather than being queued.
IAsyncDisposable
NavigationManager navManager
Bit.Butil.WindowMessaging windowMessaging
<iframe @ref="_frame" src="/frames/echo.html" title="messaging frame"></iframe>
{
private ElementReference _frame;
private ButilSubscription? _subscription;
private string Origin => new Uri(navManager.BaseUri).GetLeftPart(UriPartial.Authority);
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
// Receive. The allowed origins are the security boundary: a listener that accepts every
// origin accepts messages from anyone who can get a reference to this window.
_subscription = await windowMessaging.Listen(
m => InvokeAsync(() => { /* m.Origin, m.Deserialize<T>() */ }),
allowedOrigins: [Origin]);
}
// Send. A target is a description rather than a reference - the window behind an iframe is
// replaced on every navigation - so a message sent before the frame has loaded goes nowhere
// rather than being queued.
private async Task Send() =>
await windowMessaging.Frame(_frame)
.PostMessage(new { hello = "frame" }, targetOrigin: Origin);
public async ValueTask DisposeAsync()
{
if (_subscription is not null) await _subscription.DisposeAsync();
}
}
<meta charset="utf-8">
<title>An embedded document</title>
<script>
addEventListener('message', e => {
// The same rule on this side: a document that does not check the origin accepts messages
// from anyone who can get a reference to it.
if (e.origin !== location.origin) return;
// e.source is the window that sent it, which is how a frame answers without being told
// anything about who embedded it.
e.source.postMessage({ op: 'echo', payload: e.data, from: 'the frame' }, e.origin);
});
// Announce readiness upwards: a message posted to a frame that has not loaded goes nowhere
// rather than being queued, so the page needs to know when there is something to talk to.
if (parent !== window) parent.postMessage({ op: 'frameReady' }, location.origin);
</script>This is the handshake worth knowing: post once with a port attached, and everything after that goes over the port instead - no origin argument on every message, and nobody else listening in. The ports are transferred, so the handles you pass stop working on this side. A message that arrives carrying ports hands them over in WindowMessage.Ports, and they deliver nothing until started.
NavigationManager navManager
Bit.Butil.MessageChannel messageChannel
Bit.Butil.WindowMessaging windowMessaging
<iframe @ref="_frame" src="/frames/echo.html" title="messaging frame"></iframe>
{
private ElementReference _frame;
private MessageChannelHandle? _channel;
private string Origin => new Uri(navManager.BaseUri).GetLeftPart(UriPartial.Authority);
private async Task HandOverAPort()
{
_channel = await messageChannel.Create();
await _channel!.Port1.OnMessage(m => InvokeAsync(StateHasChanged));
await _channel.Port1.Start();
// Port2 goes to the frame, and stops working here - that transfer is what makes the line
// private.
await windowMessaging.Frame(_frame)
.PostWithPorts(new { op = "here is a line" }, Origin, [_channel.Port2]);
}
// From here on there is no origin argument and nobody else listening in.
private async Task SendOverPort() =>
await _channel!.Port1.PostMessage(new { over = "the port" });
}
<meta charset="utf-8">
<title>An embedded document</title>
<script>
addEventListener('message', e => {
if (e.origin !== location.origin) return;
// A port arrived: from here on the conversation moves off window messaging entirely.
if (e.ports && e.ports.length > 0) {
const port = e.ports[0];
port.addEventListener('message', m => {
port.postMessage({ op: 'fromFrame', echoOf: m.data });
});
// A port delivers nothing until it is started.
port.start();
e.source.postMessage({ op: 'tookPort' }, e.origin);
}
});
</script>Window.Open hands back an id; this addresses the popup behind it. Only while it is open - a closed one, or an id from a NoOpener open (which deliberately hands back no reference at all), posts nothing and returns false. That is the feature working, not a failure to report.
Bit.Butil.Window window
NavigationManager navManager
Bit.Butil.WindowMessaging windowMessaging
{
private string? _id;
private string Origin => new Uri(navManager.BaseUri).GetLeftPart(UriPartial.Authority);
// Must run inside a user gesture, or the popup is blocked.
private async Task OpenPopup() =>
_id = await window.Open("/frames/echo.html", "_blank", "popup=yes,width=420,height=320");
// False once the popup is closed, and for an id from a NoOpener open.
private async Task Post() =>
await windowMessaging.OpenedWindow(_id!)
.PostMessage(new { hello = "popup" }, targetOrigin: Origin);
}Parent is the document that embedded this one, Top the outermost of the frame tree, Opener the window that opened this one. In a top-level page opened by nobody, Parent and Top are this page itself - so a message posted there comes straight back to your own listener, which is what happens here.
NavigationManager navManager
Bit.Butil.WindowMessaging windowMessaging
{
private string Origin => new Uri(navManager.BaseUri).GetLeftPart(UriPartial.Authority);
private async Task PostUpwards()
{
await windowMessaging.Parent().PostMessage(new { up = true }, Origin);
await windowMessaging.Top().PostMessage(new { up = true }, Origin);
// A no-op on the other side of a NoOpener open, where there is deliberately no reference at
// all - that is the feature working, not a failure to report.
await windowMessaging.Opener().PostMessage(new { back = true }, Origin);
}
}API reference
ValueTask<bool> IsSupported()ValueTask<ButilSubscription> Listen(Action<WindowMessage> onMessage, string[]? allowedOrigins = null)WindowMessageTarget Frame(ElementReference iframe)WindowMessageTarget OpenedWindow(string windowId)WindowMessageTarget Parent(), Top(), Opener()ValueTask DisposeAsync()ValueTask<bool> PostMessage<T>(T value, string targetOrigin, JsonSerializerOptions? options = null)ValueTask<bool> PostBytes(byte[] data, string targetOrigin, bool transfer = true)ValueTask<bool> PostWithPorts<T>(T value, string targetOrigin, MessagePortHandle[] ports, JsonSerializerOptions? options = null)record (string Origin, bool IsBinary, string? Json, byte[]? Data, MessagePortHandle[] Ports) { T? Deserialize<T>() }