add Conn interface, WASM client with WebTransport transport

- Conn interface: abstracts datagram I/O (Read/Write/SetReadDeadline/Close)
- Client.DialConn(): create client over any Conn (not just UDP)
- cmd/mosh-wasm: WASM binary exposing moshConnect(url, key) to JS
- WebTransport adapter: browser datagrams as mosh.Conn
- Build tags: server.go and framebuffer.go excluded from js/wasm
- Shared constants moved to conn.go

Existing UDP paths unchanged. Latch unaffected.
This commit is contained in:
realrasengan
2026-03-30 12:05:33 -07:00
parent 0f15d99fbc
commit bbe416444b
9 changed files with 486 additions and 17 deletions
+9 -3
View File
@@ -12,7 +12,7 @@ import (
// Client is a mosh client. It connects to a mosh server over UDP,
// handles the SSP transport, and provides send/recv for terminal I/O.
type Client struct {
conn *net.UDPConn
conn Conn
transport *Transport
ocb *OCB
@@ -32,8 +32,8 @@ type Client struct {
wg sync.WaitGroup
}
// Dial connects to a mosh server. The key is the base64-encoded mosh key
// (with or without padding).
// Dial connects to a mosh server over UDP. The key is the base64-encoded
// mosh key (with or without padding).
func Dial(host string, port int, key string) (*Client, error) {
// Pad key for base64 if needed.
for len(key)%4 != 0 {
@@ -57,6 +57,12 @@ func Dial(host string, port int, key string) (*Client, error) {
return nil, err
}
return DialConn(conn, ocb)
}
// DialConn creates a mosh client over an existing datagram connection.
// Use this with WebTransport or other non-UDP transports.
func DialConn(conn Conn, ocb *OCB) (*Client, error) {
c := &Client{
conn: conn,
transport: NewTransport(ocb, false),
+143
View File
@@ -0,0 +1,143 @@
//go:build js && wasm
package main
import (
"encoding/base64"
"fmt"
"sync"
"syscall/js"
"time"
mosh "github.com/unixshells/mosh-go"
)
func main() {
js.Global().Set("moshConnect", js.FuncOf(moshConnect))
select {} // keep alive
}
// moshConnect(url, key) → Promise<MoshSession>
// url: WebTransport URL (e.g., "https://relay.example.com/mosh/user/device")
// key: base64-encoded mosh key
func moshConnect(this js.Value, args []js.Value) interface{} {
if len(args) < 2 {
return reject("moshConnect requires (url, key)")
}
url := args[0].String()
key := args[1].String()
handler := js.FuncOf(func(this js.Value, pargs []js.Value) interface{} {
resolve := pargs[0]
rejectFn := pargs[1]
go func() {
session, err := newSession(url, key)
if err != nil {
rejectFn.Invoke(err.Error())
return
}
resolve.Invoke(session.jsObject())
}()
return nil
})
return js.Global().Get("Promise").New(handler)
}
type session struct {
client *mosh.Client
conn *wtConn
mu sync.Mutex
closed bool
}
func newSession(url, key string) (*session, error) {
for len(key)%4 != 0 {
key += "="
}
rawKey, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return nil, fmt.Errorf("bad key: %w", err)
}
ocb, err := mosh.NewOCB(rawKey)
if err != nil {
return nil, err
}
conn, err := dialWebTransport(url)
if err != nil {
return nil, err
}
client, err := mosh.DialConn(conn, ocb)
if err != nil {
conn.Close()
return nil, err
}
return &session{client: client, conn: conn}, nil
}
func (s *session) jsObject() js.Value {
obj := js.Global().Get("Object").New()
obj.Set("send", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) < 1 {
return nil
}
data := args[0].String()
s.client.Send([]byte(data))
return nil
}))
obj.Set("resize", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) < 2 {
return nil
}
cols := args[0].Int()
rows := args[1].Int()
s.client.Resize(uint16(cols), uint16(rows))
return nil
}))
// recv(callback) — calls callback(string) whenever output is available
obj.Set("recv", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) < 1 {
return nil
}
cb := args[0]
go func() {
for {
out := s.client.Recv(60 * time.Second)
if out == nil {
s.mu.Lock()
closed := s.closed
s.mu.Unlock()
if closed {
return
}
continue
}
cb.Invoke(string(out))
}
}()
return nil
}))
obj.Set("close", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
s.mu.Lock()
s.closed = true
s.mu.Unlock()
s.client.Close()
return nil
}))
return obj
}
func reject(msg string) js.Value {
return js.Global().Get("Promise").Call("reject", msg)
}
+159
View File
@@ -0,0 +1,159 @@
//go:build js && wasm
package main
import (
"errors"
"sync"
"syscall/js"
"time"
)
// wtConn wraps a browser WebTransport connection as a mosh.Conn.
// Uses WebTransport datagrams for unreliable, unordered delivery —
// matching UDP semantics for the mosh protocol.
type wtConn struct {
transport js.Value // WebTransport instance
reader js.Value // datagrams.readable reader
writer js.Value // datagrams.writable writer
incoming chan []byte
done chan struct{}
once sync.Once
mu sync.Mutex
deadline time.Time
}
func dialWebTransport(url string) (*wtConn, error) {
wt := js.Global().Get("WebTransport").New(url)
// Wait for .ready promise
readyCh := make(chan error, 1)
wt.Get("ready").Call("then",
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
readyCh <- nil
return nil
}),
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
msg := "WebTransport connection failed"
if len(args) > 0 {
msg = args[0].Get("message").String()
}
readyCh <- errors.New(msg)
return nil
}),
)
select {
case err := <-readyCh:
if err != nil {
return nil, err
}
case <-time.After(10 * time.Second):
wt.Call("close")
return nil, errors.New("WebTransport connect timeout")
}
datagrams := wt.Get("datagrams")
reader := datagrams.Get("readable").Call("getReader")
writer := datagrams.Get("writable").Call("getWriter")
c := &wtConn{
transport: wt,
reader: reader,
writer: writer,
incoming: make(chan []byte, 256),
done: make(chan struct{}),
}
// Start reading datagrams in background
go c.readLoop()
return c, nil
}
func (c *wtConn) readLoop() {
for {
ch := make(chan js.Value, 1)
c.reader.Call("read").Call("then",
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
ch <- args[0]
return nil
}),
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
close(ch)
return nil
}),
)
select {
case <-c.done:
return
case result, ok := <-ch:
if !ok {
return
}
if result.Get("done").Bool() {
return
}
value := result.Get("value")
buf := make([]byte, value.Get("byteLength").Int())
js.CopyBytesToGo(buf, js.Global().Get("Uint8Array").New(value))
select {
case c.incoming <- buf:
default:
// drop if channel full
}
}
}
}
func (c *wtConn) Read(b []byte) (int, error) {
c.mu.Lock()
deadline := c.deadline
c.mu.Unlock()
var timer <-chan time.Time
if !deadline.IsZero() {
d := time.Until(deadline)
if d <= 0 {
return 0, errors.New("i/o timeout")
}
timer = time.After(d)
}
select {
case <-c.done:
return 0, errors.New("connection closed")
case data := <-c.incoming:
n := copy(b, data)
return n, nil
case <-timer:
return 0, errors.New("i/o timeout")
}
}
func (c *wtConn) Write(b []byte) (int, error) {
arr := js.Global().Get("Uint8Array").New(len(b))
js.CopyBytesToJS(arr, b)
c.writer.Call("write", arr)
return len(b), nil
}
func (c *wtConn) SetReadDeadline(t time.Time) error {
c.mu.Lock()
c.deadline = t
c.mu.Unlock()
return nil
}
func (c *wtConn) Close() error {
c.once.Do(func() {
close(c.done)
c.reader.Call("cancel")
c.writer.Call("close")
c.transport.Call("close")
})
return nil
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"encoding/base64"
"fmt"
"net"
"os"
"strconv"
"time"
"unicode/utf8"
mosh "github.com/unixshells/mosh-go"
)
func main() {
if len(os.Args) != 4 {
fmt.Fprintf(os.Stderr, "Usage: %s <host> <port> <base64key>\n", os.Args[0])
os.Exit(1)
}
host := os.Args[1]
port, _ := strconv.Atoi(os.Args[2])
key, err := base64.StdEncoding.DecodeString(os.Args[3])
if err != nil {
// Try raw base64 without padding.
key, err = base64.RawStdEncoding.DecodeString(os.Args[2+1])
if err != nil {
fmt.Fprintf(os.Stderr, "bad key: %v\n", err)
os.Exit(1)
}
}
ocb, err := mosh.NewOCB(key)
if err != nil {
fmt.Fprintf(os.Stderr, "ocb: %v\n", err)
os.Exit(1)
}
transport := mosh.NewTransport(ocb, false) // client mode
// Resolve host.
addrs, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
fmt.Fprintf(os.Stderr, "resolve: %v\n", err)
os.Exit(1)
}
conn, err := net.DialUDP("udp", nil, addrs)
if err != nil {
fmt.Fprintf(os.Stderr, "dial: %v\n", err)
os.Exit(1)
}
defer conn.Close()
flush := func() {
datagrams := transport.Tick()
for _, dg := range datagrams {
conn.Write(dg)
}
}
// Read incoming in background.
go func() {
buf := make([]byte, 65536)
for {
n, err := conn.Read(buf)
if err != nil {
return
}
diff := transport.Recv(buf[:n])
if diff == nil || len(diff) == 0 {
continue
}
instrs, err := mosh.UnmarshalHostMessage(diff)
if err != nil {
fmt.Printf("unmarshal error: %v\n", err)
continue
}
for _, hi := range instrs {
if len(hi.Hoststring) > 0 {
text := string(hi.Hoststring)
if !utf8.ValidString(text) {
text = fmt.Sprintf("(%d raw bytes)", len(hi.Hoststring))
}
fmt.Printf("=== hoststring (%d bytes) ===\n%s\n=== end ===\n", len(hi.Hoststring), text)
}
if hi.Width > 0 || hi.Height > 0 {
fmt.Printf("=== resize: %dx%d ===\n", hi.Width, hi.Height)
}
}
}
}()
// Tick timer.
ticker := time.NewTicker(20 * time.Millisecond)
go func() {
for range ticker.C {
flush()
}
}()
// Initial keepalive.
fmt.Println("Sending initial keepalive...")
transport.ForceNextSend()
flush()
// Resize.
fmt.Println("Sending resize 80x24...")
resize := mosh.MarshalUserMessage([]mosh.UserInstruction{
{Width: 80, Height: 24},
})
transport.SetPending(resize)
flush()
// Wait for initial output.
fmt.Println("Waiting 5 seconds for initial output...")
time.Sleep(5 * time.Second)
// Send ls + Enter.
fmt.Println("\nSending ls + Enter...")
keystroke := mosh.MarshalUserMessage([]mosh.UserInstruction{
{Keys: []byte("ls\r")},
})
transport.SetPending(keystroke)
flush()
// Wait for response.
time.Sleep(3 * time.Second)
fmt.Println("\nDone.")
ticker.Stop()
}
+36
View File
@@ -0,0 +1,36 @@
package mosh
import "time"
// Conn is a datagram-oriented connection used by the mosh transport.
// UDP, WebTransport, and other datagram transports implement this interface.
type Conn interface {
// Read reads a datagram. Returns the number of bytes read.
// Must respect deadlines set by SetReadDeadline.
Read(b []byte) (int, error)
// Write writes a datagram.
Write(b []byte) (int, error)
// SetReadDeadline sets the deadline for Read calls.
SetReadDeadline(t time.Time) error
// Close closes the connection.
Close() error
}
const (
// Direction bits in the 64-bit nonce header.
dirToServer = uint64(0) // client → server
dirToClient = uint64(1) << 63 // server → client
seqMask = ^(uint64(1) << 63) // lower 63 bits
// Minimum wire datagram: 8 (nonce) + 16 (tag) = 24 bytes.
minDatagram = 24
// Maximum payload per UDP datagram.
maxPayload = 16384
// Tick interval matches mosh's SEND_MINDELAY (8ms) for keystroke batching.
tickInterval = 8 * time.Millisecond
)
+2
View File
@@ -1,3 +1,5 @@
//go:build !js
package mosh
import (
Executable
BIN
View File
Binary file not shown.
+2 -14
View File
@@ -1,3 +1,5 @@
//go:build !js
package mosh
import (
@@ -18,20 +20,6 @@ import (
)
const (
// Direction bits in the 64-bit nonce header.
dirToServer = uint64(0) // client → server
dirToClient = uint64(1) << 63 // server → client
seqMask = ^(uint64(1) << 63) // lower 63 bits
// Minimum wire datagram: 8 (nonce) + 16 (tag) = 24 bytes.
minDatagram = 24
// Maximum payload per UDP datagram.
maxPayload = 16384
// Tick interval matches mosh's SEND_MINDELAY (8ms) for keystroke batching.
tickInterval = 8 * time.Millisecond
// Server gives up waiting for client after this.
associationTimeout = 60 * time.Second
+2
View File
@@ -1,3 +1,5 @@
//go:build !js
package mosh
import (