//go:build js && wasm package msgport import ( "errors" "sync" "syscall/js" ) // messagePort is a Port over a browser MessagePort. // // The frames are transferred, not copied: the Uint8Array that carries a frame is created for // that one send and its buffer is handed to the far end, so a filter range crosses the worker // boundary without a second copy of itself. type messagePort struct { port js.Value listener js.Func in chan []byte done chan struct{} mu sync.Mutex dead bool } // NewMessagePort wraps a MessagePort, or any object with addEventListener/postMessage. func NewMessagePort(port js.Value) (Port, error) { if port.IsUndefined() || port.IsNull() { return nil, errors.New("msgport: no port was given") } wrapped := &messagePort{ port: port, in: make(chan []byte, Buffer), done: make(chan struct{}), } wrapped.listener = js.FuncOf(wrapped.onMessage) port.Call("addEventListener", "message", wrapped.listener) // A MessagePort received through postMessage starts paused: the queue only flows once // start() is called or an onmessage handler is set. addEventListener alone does not // start it, so a worker that only listens would receive nothing at all — the port stays // paused, no frame ever arrives, and the far end waits with no error anywhere. if start := port.Get("start"); start.Type() == js.TypeFunction { port.Call("start") } return wrapped, nil } // onMessage copies an arriving frame out of the JS heap. // // A transferred ArrayBuffer arrives as the buffer; a plain Uint8Array arrives as a view. The // Uint8Array constructor copies either, which is the one place a copy is unavoidable: the // bytes have to become Go memory before the frame can be decoded. func (p *messagePort) onMessage(_ js.Value, args []js.Value) any { if len(args) == 0 { return nil } frame, err := toBytes(args[0].Get("data")) if err != nil { return nil } p.mu.Lock() defer p.mu.Unlock() if p.dead { return nil } select { case p.in <- frame: default: // The protocol cannot drain a full buffer, so a full buffer is a dead channel // rather than backpressure the event loop would have to block for. p.closeLocked() } return nil } // Send posts one frame, transferring its buffer. func (p *messagePort) Send(frame []byte) error { p.mu.Lock() dead := p.dead p.mu.Unlock() if dead { return ErrClosed } array := js.Global().Get("Uint8Array").New(len(frame)) if len(frame) > 0 { _ = js.CopyBytesToJS(array, frame) } // The second argument is the transfer list: the buffer moves instead of being copied. p.port.Call("postMessage", array, []any{array.Get("buffer")}) return nil } // Receive is the incoming frames, closed when the port is gone. func (p *messagePort) Receive() <-chan []byte { return p.in } // Close detaches the listener and ends the receive side. func (p *messagePort) Close() error { p.mu.Lock() defer p.mu.Unlock() p.closeLocked() return nil } // closeLocked ends the port. It runs under the lock the message handler takes, so a frame can // never be handed to a closed channel. func (p *messagePort) closeLocked() { if p.dead { return } p.dead = true close(p.in) close(p.done) p.port.Call("removeEventListener", "message", p.listener) p.listener.Release() } // toBytes reads an ArrayBuffer or a typed-array view into Go memory. func toBytes(value js.Value) ([]byte, error) { if value.IsUndefined() || value.IsNull() { return nil, errors.New("msgport: an empty message") } switch value.Type() { case js.TypeObject: default: return nil, errors.New("msgport: a message that is not binary") } // new Uint8Array(ArrayBuffer) views the buffer; new Uint8Array(typedArray) copies it. view := js.Global().Get("Uint8Array").New(value) out := make([]byte, view.Length()) if len(out) > 0 { _ = js.CopyBytesToGo(out, view) } return out, nil } // Done is closed when the port is gone. func (p *messagePort) Done() <-chan struct{} { return p.done }