webdial

package module
v0.13.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 19 Imported by: 0

README

webdial

net.Conn over HTTP. Uses WebSocket when available, falls back to SSE+POST. WebTransport (HTTP/3) is available opt-in.

screenshot

Both sides get a standard net.Conn (Go) or an equivalent read/write/close interface (JavaScript), so any stream-oriented protocol works over it.

Endpoint paths are used exactly as supplied. Include the trailing slash when the handler is mounted on a subtree such as /wd/; existing query parameters are preserved.

Go

Install
go get github.com/jpillora/webdial
Server

*Server implements http.Handler, so mount it directly:

srv := webdial.NewServer()

mux := http.NewServeMux()
mux.Handle("/wd/", srv)

go http.ListenAndServe(":8080", mux)

for {
    conn, err := srv.Accept()
    if err != nil {
        break
    }
    go func() {
        defer conn.Close()
        io.Copy(conn, conn) // echo
    }()
}

srv.Accept() returns a net.Conn. Use it with any protocol that works over a byte stream.

SSE data POSTs are limited to 1 MiB each by default. Configure a different limit when constructing the server (a negative value explicitly disables it):

srv.MaxPostBytes = 4 << 20 // 4 MiB

Oversized bodies receive HTTP 413, which both clients return as a write error. Successful POST bodies for one SSE connection are delivered contiguously and one at a time. When clients issue concurrent writes, the body that acquires the server first is delivered first; callers that require a specific order should await each write's successful response before starting the next.

WebSocket origin policy

WebSocket handshakes use a secure same-origin policy by default. Browser requests whose Origin host does not match the request Host are rejected; non-browser clients that omit Origin, including the Go and Node.js clients, are accepted.

If a trusted web application is hosted on a different origin, configure an explicit allowlist on that server:

srv.CheckOrigin = func(r *http.Request) bool {
    switch r.Header.Get("Origin") {
    case "https://app.example.com", "https://admin.example.com":
        return true
    default:
        return false
    }
}

Cross-origin WebSockets should also be protected with explicit authentication. Avoid a blanket return true: browsers do not apply CORS protections to WebSocket handshakes.

WebTransport (HTTP/3)

WebTransport lives in a subpackage so that programs using only WebSocket and SSE do not link quic-go:

go get github.com/jpillora/webdial/wt

Importing only webdial links no quic-go code and adds nothing to your go.sum; quic-go appears in the module graph, so manifest-based scanners may list it. Adding WebTransport costs roughly 2 MB of stripped binary.

It needs its own listener, because HTTP/3 runs over UDP. Point it at the same core server and one Accept loop serves every transport:

srv := webdial.NewServer()

mux := http.NewServeMux()
mux.Handle("/wd/", srv)
go http.ListenAndServe(":8080", mux) // ws + sse

wts := wt.NewServer(srv)
wts.Addr = ":8443"
wts.TLSConfig = myTLSConfig
h3 := http.NewServeMux()
h3.Handle("/wd/", wts)
wts.Handler = h3
go wts.ListenAndServe() // webtransport, same Accept loop

wt.Server answers WebTransport itself and delegates every other request to the core server, so the same endpoint also serves the SSE fallback over HTTP/3. It honours CheckOrigin with the same secure default as WebSocket, and inherits the core server's keep-alive interval as the QUIC keep-alive period.

Because a WebTransport session lives only as long as its CONNECT stream, the handler blocks until the session ends; this is handled internally.

Client
conn, err := webdial.Dial(ctx, "http://localhost:8080/wd/")
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

conn.Write([]byte("hello"))

buf := make([]byte, 1024)
n, err := conn.Read(buf)
fmt.Println(string(buf[:n])) // "hello"

Dial tries WebSocket first and falls back to SSE+POST automatically. The returned net.Conn works the same regardless of transport.

WebTransport is reached through its own entry point, since the root package does not import quic-go:

conn, err := wt.Dial(ctx, "https://localhost:8443/wd/")

Use wt.Dialer to supply a TLSConfig — for example to trust a development certificate. Note the HTTP/3 endpoint is usually on a different port from the HTTP one, even when the path is identical.

As with net.Dialer.DialContext, ctx controls connection establishment only. Canceling it after Dial returns does not close the established connection; call conn.Close() to end the connection.

JavaScript

The ESM client (client.mjs) works in both browsers and Node.js 22+. Zero dependencies.

Install
npm install webdial

Or use it directly from a <script type="module">:

<script type="module">
import { dial } from "/path/to/client.mjs";
</script>
Usage
import { dial } from "webdial";

const conn = await dial("http://localhost:8080/wd/");

// Send text
await conn.write("hello");

// Send binary
await conn.write(new Uint8Array([1, 2, 3]));

// Read (returns Uint8Array, or null on close)
const data = await conn.read();
console.log(new TextDecoder().decode(data));

// Close
await conn.close();
Options

Force a specific transport, or give an ordered list to try in turn:

const conn = await dial(url, { transport: "ws" });  // WebSocket only
const conn = await dial(url, { transport: "sse" }); // SSE+POST only
const conn = await dial(url, { transport: ["wt", "ws", "sse"] });

By default, dial tries WebSocket first and falls back to SSE+POST. An unrecognised transport name throws a TypeError.

WebTransport is opt-in and never tried automatically: it needs an HTTP/3 listener on a separate UDP port, so probing it would cost every dial a failed connection attempt wherever it is not deployed — and in the restrictive networks this library exists for, that failure only arrives after a QUIC handshake timeout.

Because the HTTP/3 listener is usually on another port, wtURL overrides the endpoint while conn.url keeps reporting the URL you passed. wt is handed to the WebTransport constructor:

const conn = await dial("https://example.com/wd/", {
  transport: "wt",
  wtURL: "https://example.com:8443/wd/",
  wt: { serverCertificateHashes: [{ algorithm: "sha-256", value: hashBytes }] },
});

WebTransport applies real back-pressure: when unread data reaches maxBufferedBytes the client stops reading from the stream, which slows the sender through QUIC flow control instead of failing the connection the way SSE must.

Browser support is Chromium-based browsers and Firefox; dial reports webdial: WebTransport is not supported where the API is missing, so a transport list falls through to the next entry. serverCertificateHashes, used to pin a self-signed development certificate, is Chromium-only and requires an ECDSA P-256 certificate valid for no more than two weeks — see testdata/devserver for a working example.

The SSE transport decodes control events in the background, so keep-alives and remote closes are handled even when the application is not calling read(). Decoded data waiting for a reader is bounded to 1 MiB and 1,024 events by default. Use maxBufferedBytes to change the byte limit:

const conn = await dial(url, {
  transport: "sse",
  maxBufferedBytes: 256 * 1024,
});

If either receive-buffer limit would be exceeded, the connection closes rather than silently dropping data. Its pending and subsequent reads reject with an SSE receive-buffer error, and subsequent writes report a closed connection.

Connection properties
  • conn.transport — "ws", "sse" or "wt"
  • conn.url — the base URL used to connect

Transports

Transport Mechanism Binary Requirements
ws WebSocket native WebSocket support
sse Server-Sent Events (read) + POST (write) base64 HTTP/1.1+
wt WebTransport bidirectional stream (HTTP/3) native HTTP/3 listener, HTTPS

WebSocket is preferred. SSE+POST is the fallback for environments where WebSocket connections are blocked (e.g. some corporate proxies). WebTransport is opt-in on both clients: it needs a separate HTTP/3 listener, and UDP is exactly what the restrictive networks SSE exists for tend to block.

Protocol

The server is a single http.Handler that routes by content-negotiation:

  • Upgrade: websocket header — WebSocket upgrade, binary frames carry data
  • GET with Accept: text/event-stream — SSE stream; first event is sid (session ID), subsequent d events carry base64-encoded data, close event signals shutdown
  • POST with ?s=<sid> — write body bytes to the session; append &close=1 to close

WebTransport is served from a separate HTTP/3 listener on the same path:

  • HTTP/3 extended CONNECT — establishes the session; routing keys on the method alone, since the protocol token differs between drafts
  • one client-initiated bidirectional stream carries the byte stream, with no framing and no base64
  • datagrams carry ping:<ts>/pong:<ts>, the same control vocabulary the WebSocket transport puts in text frames; the peer echoes the timestamp so the client can measure latency
  • QUIC PING frames provide the keep-alive, so there is no webdial-level server heartbeat, matching WebSocket

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrServerClosed = errors.New("webdial: server closed")

ErrServerClosed is returned by Accept and Push after the server is closed.

Functions

func Dial

func Dial(ctx context.Context, baseURL string) (net.Conn, error)

Types

type Server

type Server struct {
	// KeepAlive is the interval between keep-alive pings.
	// Zero means 25 seconds. Negative means disabled.
	KeepAlive time.Duration
	// CheckOrigin, when non-nil, validates the Origin header of WebSocket
	// upgrade requests. A nil function uses Gorilla WebSocket's secure default:
	// requests without an Origin are accepted, while requests with an Origin
	// must have an Origin host matching the request Host. CheckOrigin may be
	// called concurrently and must be safe for concurrent use.
	CheckOrigin func(*http.Request) bool
	// MaxPostBytes limits the body of each SSE data POST. Zero means 1 MiB.
	// Negative disables the limit. Oversized requests receive HTTP 413.
	MaxPostBytes int64
	// CompressionLevel is the flate level for per-message WS compression
	// (1-9, or flate.DefaultCompression). Zero means flate.BestSpeed.
	CompressionLevel int
	// WriteTimeout bounds a single WS frame write, so a peer that stops
	// reading cannot pin the connection forever. Zero means 30 seconds.
	// Negative means unbounded. Ignored once the caller sets its own write
	// deadline via the net.Conn interface.
	WriteTimeout time.Duration
	// contains filtered or unexported fields
}

func NewServer

func NewServer() *Server

func (*Server) Accept

func (s *Server) Accept() (net.Conn, error)

func (*Server) Close

func (s *Server) Close() error

func (*Server) KeepAliveInterval added in v0.13.1

func (s *Server) KeepAliveInterval() time.Duration

KeepAliveInterval reports the effective keep-alive interval: KeepAlive, or 25 seconds when it is zero. A negative value means keep-alives are disabled. Transports implemented in other packages use it to match this server's heartbeat pacing.

func (*Server) Push added in v0.13.1

func (s *Server) Push(ctx context.Context, conn net.Conn) error

Push hands an externally established connection to a caller of Accept, so a single Accept loop can serve transports implemented outside this package.

It blocks until the connection is accepted, ctx is done, or the server is closed. In the latter two cases Push closes conn and returns a non-nil error: a caller must never be left holding a connection nothing will read.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

Directories

Path Synopsis
Package wt serves and dials webdial connections over WebTransport (HTTP/3).
Package wt serves and dials webdial connections over WebTransport (HTTP/3).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL