Ну как, разрабатываете протокол и воплощаете.
Протоколы, которые я делал, устроены примерно по такой схеме:
На каналы WebSocket ставится мультиплексор в оба направления. Мультиплексор различает новые запросы и ответы на старые.
Инициатор нового запроса генерирует уникальный ключ, по которому он собирается ждать ответ, и шлёт ключ с запросом. Для обработки новых запросов запускается новая асинхронная функция (горутина). Когда она закончила, она отправляет результат с указанным ключом.
Для получения ответов мультиплексор на стороне JS хранит изнанки обещаний (кортеж из замыканий resolve и reject) в карте по ключам запроса. При получении мультиплексором ответа из карты по ключу извлекается такая изнанка, и вызывается resolve или reject.
В JavaScript, таким образом, асинхронная функция может сделать await new Promise, с функцией, которая сохранит аргументы-изнанку resolve&reject в карте мультиплексирования, отправит запрос, и такое выражение в асинхронной (async) функции вернёт сразу ответ или исключение. Аналогично делается в Go.
В итоге получается двусторонний RPC, с обоих сторон вызываемый в синхронном стиле. Придумываете, как сериализовать/распечатывать аргументы, и делаете.
На стороне Go я бы в качестве аналога кортежа из замыканий resolve и reject использовал монитор. Монитор защищает двух- или одноэлементный объект, в котором одна ячейка выделена для будущего результата, а вторая для ошибки, или они совмещены. Такие объекты с мониторами складываются в карту с ключами. Есть способ при получении ответа от JS положить значение или ошибку в монитор так, чтоб сработала условная переменная. Условная переменная срабатывает, когда под защитой монитора что-то появляется. Горутины, которые хотят от JS ответ, кладут объект с монитором в карту, отправляют по WS запрос и повисают на ожидании условной переменной.