Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using MessagePorts (+ Transferables) over ContextBridge #27024

Closed
3 tasks done
willium opened this issue Dec 15, 2020 · 32 comments
Closed
3 tasks done

Using MessagePorts (+ Transferables) over ContextBridge #27024

willium opened this issue Dec 15, 2020 · 32 comments

Comments

@willium
Copy link

willium commented Dec 15, 2020

Transferables aren't listed in the relevant documentation, but given their type, it seems like the preferred case would be that they are indeed "transferred" over the contexts. Is that possible?

In any case, is there a good path forward for using MessagePorts alongside contextBridge? How about passing transferables over MessagePorts without incurring the intermediary copy across the bridge?

Here is my best guess about how that might work (though, again, transferables don't seem to work as expected).

import { contextBridge } from "electron"

contextBridge.exposeInMainWorld("electron", {
  foo: (bar) => bar,
  connect: (port, connection) => {
		if (!validPorts.includes(port)) {
			throw new Error(`Invalid Channel "${port}"`)
		}

		const callback = ({ ports }, _) => {
                        // really don't know how this API could be expanded to support juggling multiple ports in one "connection"
			if (ports.length !== 0) throw new Error("Connection requires (1) port")

			ports.forEach((messagePort) => {

				const post = (outgoing, transferables) => {
					messagePort.postMessage(outgoing, transferables)
				}

				const disconnect = () => {
					ipcRenderer.removeListener(port, callback)
				}

				messagePort.onmessage = ({ data }) => {
					connection(data, post, disconnect)
				}
				messagePort.onmessageerror = ({ data }) => {
					connection(data, post, disconnect)
				}

				messagePort.start()
			})
		}

		ipcRenderer.on(port, callback)
	},
})

Preflight Checklist

  • I have read the Contributing Guidelines for this project.
  • I agree to follow the Code of Conduct that this project adheres to.
  • I have searched the issue tracker for an issue that matches the one I want to file, without success.
@nornagon
Copy link
Member

Passing MessagePorts across the context bridge is not currently supported. It doesn't look like your example code does that, though it's difficult to tell as it's incomplete. Would you be able to provide a gist (preferably using Electron Fiddle) that shows the issue you're having?

@willium
Copy link
Author

willium commented Dec 15, 2020

Thanks Jeremy, copying over my previous message for thread continuation:

Issues I've observed:

  1. Transferables seems to be copied as they go over the ContextBridge, is there anyway to send Transferables using MessagePorts without incurring a copy step as they go over the ContextBridge?
  2. What is the best way to interface with the MessagePort on the renderer thread with context isolation? Is there some way to attach the MessagePort to the window with ContextBridge, or must it done within a closure?

The docs suggest this pattern:

contextBridge.exposeInMainWorld(
  'electron',
  {
    doThing: () => ipcRenderer.send('do-a-thing')
  }
)

but it's not straightforward to use this pattern with a MessagePort, as its only accessible within the callback of

ipcRender.on("port", ({ port }) => { ... })

e.g.

type PortName = string
type Channel = string
type Payload = object

type Reply = (payload: Payload) => void
type Callback = (payload: Payload, reply: Reply, remove: () => void) => void

type Post = (payload: Payload, transferables: Transferable[]) => void

type Connection = (
   payload: Payload,
   post: Post,
   reply: Reply,
   disconnect: () => void,
) => void

contextBridge.exposeInMainWorld("electron", {
   connect: (port: PortName, connection: Connection) => {
   	if (!validPorts.includes(port)) {
   		throw new Error(`Invalid Channel "${port}"`)
   	}

   	const wrappedCallback = ({ ports }: IpcRendererEvent, _: Payload) => {
   		const [messagePort] = ports
   		if (!messagePort) throw new Error("Connection requires (1) port")

   		const post: Post = (outgoing: Payload, transferables: Transferable[]) => {
   			messagePort.postMessage(outgoing, transferables)
   		}

   		const reply: Reply = (outgoing: Payload) => {
   			// same channel (channel should also be in outgoing payload)
   			messagePort.postMessage(outgoing)
   		}

   		const disconnect = () => {
   			ipcRenderer.removeListener(port, wrappedCallback)
   		}

   		messagePort.onmessage = ({ data }: MessageEvent<Payload>) => {
   			connection(data, post, reply, disconnect)
   		}
   		messagePort.onmessageerror = ({ data }: MessageEvent<Payload>) => {
   			connection(data, post, reply, disconnect)
   		}

   		messagePort.start()
   	}

   	ipcRenderer.on(`${port}:port`, wrappedCallback)
   },
})

@willium
Copy link
Author

willium commented Dec 15, 2020

Passing MessagePorts across the context bridge is not currently supported. It doesn't look like your example code does that, though it's difficult to tell as it's incomplete.

That's good to know and makes sense. In that case, I'm attempting to work out the best way to attach listeners to and post messages over MessagePorts by exposing methods on the ContextBridge. Unfortunately, that's complicated by the fact that the port is received in a callback, and that the "state" of the ContextBridge is immutable (can't add/remove methods once the port is attached)

@nornagon
Copy link
Member

You might try window.postMessage, which can pass transferrables between contexts.

e.g: https://gist.github.com/e95abf568696723caa06ba95c4cc2f78

// preload.js
window.onload = () => {
  const {port1, port2} = new MessageChannel
  window.postMessage('hi', '*', [port2])
  port1.onmessage = (e) => {
    console.log('isolated context got message:', e.data)
  }
}
// renderer.js
window.onmessage = (e) => {
  e.ports[0].postMessage('hi from main world')
}

@willium
Copy link
Author

willium commented Dec 15, 2020

Does that work with contextIsolation: true? I was under the impression that you can't attach things to the window object directly in the preload anymore (docs)

@willium
Copy link
Author

willium commented Dec 15, 2020

I just tried your example in the Fiddle, seems to work well. I hadn't realized you could send messages between the preload and the renderer using postMessage. So it seems like the message port communication would happen parallel to and outside of the contextBridge by chaining postMessage commands between main <-> preload <-> renderer

@nornagon
Copy link
Member

there's no need to chain anything, you can just pass ports around.

e.g. if the isolated world (i.e. preload) receives a message port, you can just window.postMessage it to the main world and use it directly.

@nornagon
Copy link
Member

The main reason to use contextBridge is for synchronous messaging between the isolated world and main world. If you don't need synchronous messaging, window.postMessage works just as well.

@willium
Copy link
Author

willium commented Dec 15, 2020

is the use of window.postMessage between renderer and preload documented somewhere? Very useful...

So something like

contextBridge.exposeInMainWorld(
  'electron',
  {
    connect: () => {
       ipcRenderer.on('port', ({ ports }) => { 
          window.postMessage('port', undefined, ports)
       })
    }
  }
)

(I understand this is still a synchronous pattern, but I'm wondering about being able to add/remove the listener from renderer which would be synchronous with connect/disconnect

should work if the port is coming from main.js?

Also curious about window.onload in preload.js. I've seen a link to process.once('loaded') but never window used in such a way

@nornagon
Copy link
Member

the isolated world is connected to the DOM just the same as the main world, it's just the JS contexts that are separated.

We could probably expand https://www.electronjs.org/docs/tutorial/context-isolation.

Things that should work just fine from the isolated world:

  • manipulating the DOM (e.g. changing attributes, inserting/removing elements, drawing to a canvas)
  • listening to events (e.g. document.addEventListener(...), window.onload, window.onmessage)
  • all the usual web APIs (e.g. fetch, XHR, WebAudio)
  • postMessage
  • ... lots of other things

Things that aren't shared:

  • custom values on DOM objects won't be visible to the other world (e.g. document.body.someCustomValue = {x: 'y'} won't be readable from the other world)
  • prototypes aren't shared (e.g. String.prototype.myCustomStringFn = () => {...} won't affect String.prototype in the other world)
  • unhandled exceptions caught via window.onerror won't propagate to the other world
  • ... probably other things too.

@willium
Copy link
Author

willium commented Dec 18, 2020

From MDN:

targetOrigin
Specifies what the origin of targetWindow must be for the event to be dispatched, either as the literal string "*" (indicating no preference) or as a URI. If at the time the event is scheduled to be dispatched the scheme, hostname, or port of targetWindow's document does not match that provided in targetOrigin, the event will not be dispatched; only if all three match will the event be dispatched. This mechanism provides control over where messages are sent; for example, if postMessage() was used to transmit a password, it would be absolutely critical that this argument be a URI whose origin is the same as the intended receiver of the message containing the password, to prevent interception of the password by a malicious third party. Always provide a specific targetOrigin, not *, if you know where the other window's document should be located. Failing to provide a specific target discloses the data you send to any interested malicious site.

is there a way to specify either the isolated (node-ish) preload context or the renderer (js) context as the targetOrigin when using window.postMessage to communicate between the two?

@nornagon
Copy link
Member

@willium both contexts have the same origin. there's no need to specify the origin because they're guaranteed to be same-origin, so * is as secure as any other choice. If you really want to specify an origin, window.location.origin will work.

@willium
Copy link
Author

willium commented Dec 18, 2020

I suppose I'm more concerned about discriminating the target between the two than security so that we don't get caught in an infinite loop for back and forth communication

(Gist)

// preload.js

window.onload = () => {
  window.postMessage('ping', '*')
  window.onmessage = (e) => {
    console.log(e.data)
    window.postMessage("pang", "*")
  }
}
// renderer.js

window.onmessage = (e) => {
  console.log(e.data)
  window.postMessage("pong", "*")
}

@nornagon
Copy link
Member

I'd suggest using a MessageChannel and passing one end to the other context.

@willium
Copy link
Author

willium commented Dec 18, 2020

ah, clever. that would work. alas, so much plumbing 😄

  • Main <-ipcMain/ipcRenderer-> Server Preload <-MessageChannel-> browser
  • Main <-ipcMain/ipcRenderer-> Renderer Preload <-MessageChannel-> browser
  • sending a message channel from one preload to the other so that Server/Renderer can talk as well

@nornagon
Copy link
Member

What are you trying to accomplish? Communication between the main process and the main world in the renderer?

@willium
Copy link
Author

willium commented Dec 18, 2020

Paradoxically, the main reason I want the ability to discriminate with window.postMessage is to allow the browser process to be able to acknowledge receipt of the message port. It seems there is no easy way to do that.

The best I can come up with is:

// preload.js

window.onload = () => {
  window.postMessage('ping', '*')
  window.onmessage = (e) => {
    // this might get "ping" even though it's after
    // because we have no guarentee about execution order
    if (e.data === "ping") return
    console.log("recieved reply", e.data)
  }
}
// renderer.js

window.onmessage = (e) => {
  console.log("received message", e.data)
  window.onmessage = undefined // unsub or we loop ourselves
  window.postMessage("pong", "*")
}

@willium
Copy link
Author

willium commented Dec 18, 2020

What are you trying to accomplish? Communication between the main process and the main world in the renderer?

that, as well as communication between multiple renderer processes over message channels

@nornagon
Copy link
Member

allow the browser process to be able to acknowledge receipt of the message port

I'm not sure what this means exactly, there shouldn't be any trouble discriminating between messages from the browser process and messages from the renderer process. they don't have the same problem as between the isolated/main worlds in the renderer with both receiving all postMessages.

@willium
Copy link
Author

willium commented Dec 18, 2020

sorry, I'm mixing up my terminology—it's super hard to be clear about "main world" and "electron main process". When I've been saying "browser" I mean "main world" with DOM and vanilla JS on the other side of the contextBridge. I see you guys call it "main world"

I'm creating a message channel in the preload, sending one port to the main world, and I'd like the main world to say "thanks, got the channel" to the preload. this would have to be over window.postMessage as I'm using contextIsolation. does that make sense? (the other port of the message channel goes to the electron main process to then be forwarded on to another renderer process, and you're right there is no trouble there except for just the sheer amount of plumbing needed)

@willium
Copy link
Author

willium commented Dec 18, 2020

as an aside, it would be nice to not have "main" refer to two different things within electron-land.

@nornagon
Copy link
Member

I'd like the main world to say "thanks, got the channel" to the preload

What's your motivation for this? There shouldn't be any reason for the message to not go through.

You could send a message back along the message channel from the main world in order to indicate receipt, if you like.

If the other end of the channel ends up getting garbage collected (because it wasn't received and handled), you'll eventually get a close event on the other end of the channel. This is an event we've added on top of the Web spec to make MessagePorts more useful, you can read about it here: https://www.electronjs.org/docs/api/message-port-main#event-close

e.g.

// isolated world
const {port1, port2} = new MessageChannel
window.postMessage({}, '*', [port2])
port1.onmessage = (e) => {
  if (e.data.type === 'ack') {
    // main world received the port, continue
  }
}
port1.onclose = () => {
  // the main world closed their end of the channel, either
  // explicitly or implicitly (by dropping the reference)
}
// main world
window.onmessage = (e) => {
  const port = e.ports[0]
  port.postMessage({type: 'ack'});
  // ... etc
}

@willium
Copy link
Author

willium commented Dec 18, 2020

yeah, I've noticed the onclose event—very neat.
does that mean
this line

const {port1, port2} = new MessageChannel

should've been

const {port1, port2} = new MessageChannelMain

?

It's a bit confusing because the preload has access to both, right? I've only been using MessageChannel in the preload.

@willium
Copy link
Author

willium commented Dec 18, 2020

What's your motivation for this? There shouldn't be any reason for the message to not go through.

It's not specifically motivated, so I understand an eye roll at my overuse of 🤝 haha

@nornagon
Copy link
Member

nornagon commented Dec 18, 2020

MessageChannelMain is only relevant in the main process (i.e. the one with BrowserWindow and session etc.). That process does not have Blink so there's no MessageChannel in that process, we had to define our own. MessageChannelMain is not available in the renderer process at all, not in the isolated world or the main world.

The close event is present on both the main-process MessageChannelMain and the renderer-side Blink MessageChannel. We added a patch to Blink for the latter.

@nornagon
Copy link
Member

I'm closing this issue as I don't think there's anything actionable here. I agree our docs could use improvement but I'm not exactly sure how. I'd be happy to hear some specific suggestions (or PRs) though!

@willium
Copy link
Author

willium commented Dec 18, 2020

The close event is present on both the main-process MessageChannelMain and the renderer-side Blink MessageChannel. We added a patch to Blink for the latter.

awesome, didn't know that!

thanks for your help—I'll follow up with some doc PRs when I've had some time to stew and test

@willium
Copy link
Author

willium commented Dec 18, 2020

The close event is present on both the main-process MessageChannelMain and the renderer-side Blink MessageChannel. We added a patch to Blink for the latter.

is this only available in Blink on Electron? or Blink everywhere? seems the TypeScript types need updating
image

@nornagon
Copy link
Member

@willium
Copy link
Author

willium commented Dec 18, 2020

Cool. I'll look into the feasibility of extending electron's typings to extend the dom.d.ts as well

@RealAlphabet
Copy link

Please excuse me for bringing this already closed discussion back to life. Can transferable objects be transferred between a BrowserWindow and the Render process ? According to #34905, it doesn't seem so.

@paralin
Copy link

paralin commented Mar 7, 2024

According to #27024 (comment) MessagePort cannot be sent over ContextBridge, is this still the case?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants