Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test-build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.25
go-version: 1.26

- name: Build for js/wasm
run: GOOS=js GOARCH=wasm go build ./...
Expand All @@ -63,7 +63,7 @@ jobs:
run: GOOS=js GOARCH=wasm go vet ./...

- name: Install wasmbrowsertest
run: go install github.com/agnivade/wasmbrowsertest@v0.11.0
run: go install github.com/agnivade/wasmbrowsertest@ff350cc7eb281242c0572c9e068246c0916d29ec

- name: Allow Chrome sandbox (Ubuntu 24 AppArmor)
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

- Add transparent Warp StreamBus transport selection through the top-level
`transport` manifest key or `RFW_TRANSPORT`.
- Serve bounded StreamBus queues over WebTransport and HTTP/3 while preserving
WebSocket as an automatic compatibility fallback.
- Keep SSC components, actions, hydration, sessions, replay and broadcasts
transport-independent.

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

rfw is "Phoenix LiveView for Go". It lets you build interactive, real-time web apps using Server Side Computed (SSC) components.

Instead of writing a REST API and a frontend framework, you write Go. rfw handles the WebSocket synchronization and DOM updates for you. It is ideal for:
Instead of writing a REST API and a frontend framework, you write Go. rfw handles synchronization over WebSocket or Warp StreamBus and updates the DOM for you. It is ideal for:
- Real-time dashboards
- Internal admin tools
- Control planes
Expand Down Expand Up @@ -125,7 +125,7 @@ Using rfw in production or in a side project? Open a PR and add yourself here.

## Server Side Computed (SSC)

SSC is the core of rfw. Most application logic runs on the server, while the browser loads a lightweight binary to hydrate the HTML. The server and client keep state synchronized through a persistent WebSocket connection.
SSC is the core of rfw. Most application logic runs on the server, while the browser loads a lightweight binary to hydrate the HTML. The server and client keep state synchronized through WebSocket or Warp StreamBus over WebTransport. The transport is selected through `rfw.json` or `RFW_TRANSPORT` without changing application code.

Components use host signal types (`t.HInt`, `t.HString`, etc.) to declare server-synced bindings. See the [SSC guide](./docs/articles/guide/ssc.md) for more details.

Expand Down
14 changes: 11 additions & 3 deletions cmd/rfw/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,21 @@ func pluginsConfig(explicit map[string]json.RawMessage) map[string]json.RawMessa

// Build compiles the configured application and runs its build plugins.
func Build() error {
shape := buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser}
shape := buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser, hostWire: hostTransportWebSocket}
if data, readErr := os.ReadFile("rfw.json"); readErr == nil {
decoded, err := decodeBuildShape(data)
if err != nil {
return err
}
shape = decoded
}
if configured := strings.TrimSpace(os.Getenv("RFW_TRANSPORT")); configured != "" {
transport, err := parseHostTransport(configured)
if err != nil {
return err
}
shape.hostWire = transport
}
if err := plugins.Configure(pluginsConfig(shape.plugins)); err != nil {
return fmt.Errorf("failed to configure plugins: %w", err)
}
Expand Down Expand Up @@ -166,7 +173,7 @@ func Build() error {
// is served by something that will not, so the client picks an artifact by
// name instead, and an embedded build is read from the container's local
// asset handler, which negotiates nothing either.
if err := writeClientConfig(clientDir, shape.host, version, encodings, shape.negotiates(), !isDev, shape.delivery, shape.transport); err != nil {
if err := writeClientConfig(clientDir, shape.host, version, encodings, shape.negotiates(), !isDev, shape.delivery, shape.transport, shape.hostWire); err != nil {
return fmt.Errorf("failed to write client config: %w", err)
}
if err := stampIndexHTML(filepath.Join(clientDir, "index.html"), version); err != nil {
Expand Down Expand Up @@ -304,7 +311,7 @@ func readWasmExec() ([]byte, error) {
// bundle it has a compressed copy of. That is also why the delivery mode is
// stamped explicitly instead of being inferred from an empty encoding list,
// which a failed compression step would produce too.
func writeClientConfig(clientDir, host, wasmVersion string, encodings []Encoding, negotiated, production bool, deliveryMode delivery, transport sscTransport) error {
func writeClientConfig(clientDir, host, wasmVersion string, encodings []Encoding, negotiated, production bool, deliveryMode delivery, transport sscTransport, hostWire hostTransport) error {
var b strings.Builder
b.WriteString("// Generated by rfw build. Do not edit.\n")
if h := strings.TrimSpace(host); h != "" {
Expand All @@ -319,6 +326,7 @@ func writeClientConfig(clientDir, host, wasmVersion string, encodings []Encoding
fmt.Fprintf(&b, "window.RFW_WASM_ENCODINGS = [%s];\n", strings.Join(names, ", "))
fmt.Fprintf(&b, "window.RFW_WASM_NEGOTIATED = %t;\n", negotiated)
fmt.Fprintf(&b, "window.RFW_SSC_TRANSPORT = %q;\n", string(transport))
fmt.Fprintf(&b, "window.RFW_TRANSPORT = %q;\n", string(hostWire))
mode := "development"
if production {
mode = "production"
Expand Down
9 changes: 5 additions & 4 deletions cmd/rfw/build/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ func TestWriteClientConfig(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
encodings := []Encoding{EncodingBrotli, EncodingGzip}
if err := writeClientConfig(".", "wss://example.com/rfw", "abc123", encodings, true, true, deliveryNetwork, sscTransportBrowser); err != nil {
if err := writeClientConfig(".", "wss://example.com/rfw", "abc123", encodings, true, true, deliveryNetwork, sscTransportBrowser, hostTransportStreamBus); err != nil {
t.Fatalf("writeClientConfig: %v", err)
}

Expand All @@ -345,6 +345,7 @@ func TestWriteClientConfig(t *testing.T) {
`window.RFW_WASM_ENCODINGS = ["br", "gzip"];`,
`window.RFW_WASM_NEGOTIATED = true;`,
`window.RFW_SSC_TRANSPORT = "browser";`,
`window.RFW_TRANSPORT = "streambus";`,
`window.RFW_BUILD_MODE = "production";`,
} {
if !strings.Contains(got, want) {
Expand All @@ -359,7 +360,7 @@ func TestWriteClientConfig(t *testing.T) {
func TestWriteClientConfigDescribesAnUncompressedBuild(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := writeClientConfig(".", "", "devbuild", nil, false, false, deliveryNetwork, sscTransportBrowser); err != nil {
if err := writeClientConfig(".", "", "devbuild", nil, false, false, deliveryNetwork, sscTransportBrowser, hostTransportWebSocket); err != nil {
t.Fatalf("writeClientConfig: %v", err)
}
config, err := os.ReadFile("rfw_config.js")
Expand Down Expand Up @@ -387,7 +388,7 @@ func TestWriteClientConfigDescribesAnUncompressedBuild(t *testing.T) {
func TestWriteClientConfigDescribesAnEmbeddedBuild(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := writeClientConfig(".", "wss://api.example.com/ws", "abc123", nil, false, true, deliveryEmbedded, sscTransportCapacitor); err != nil {
if err := writeClientConfig(".", "wss://api.example.com/ws", "abc123", nil, false, true, deliveryEmbedded, sscTransportCapacitor, hostTransportWebSocket); err != nil {
t.Fatalf("writeClientConfig: %v", err)
}
config, err := os.ReadFile("rfw_config.js")
Expand Down Expand Up @@ -415,7 +416,7 @@ func TestWriteClientConfigDescribesAnEmbeddedBuild(t *testing.T) {
func TestWriteClientConfigDefinesEveryGlobalTheLoaderReads(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := writeClientConfig(".", "", "abc123", []Encoding{EncodingGzip}, false, true, deliveryNetwork, sscTransportBrowser); err != nil {
if err := writeClientConfig(".", "", "abc123", []Encoding{EncodingGzip}, false, true, deliveryNetwork, sscTransportBrowser, hostTransportWebSocket); err != nil {
t.Fatalf("writeClientConfig: %v", err)
}
config, err := os.ReadFile("rfw_config.js")
Expand Down
36 changes: 34 additions & 2 deletions cmd/rfw/build/delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ type delivery string
// origin boundary.
type sscTransport string

// hostTransport names the browser-to-host wire transport used by SSC. It is
// orthogonal to sscTransport: the browser runtime can use WebSocket or
// StreamBus, while the Capacitor runtime keeps using its native bridge.
type hostTransport string

const (
// deliveryNetwork is the default. The bundle is downloaded, so the build
// writes compressed artifacts and the loader picks one.
Expand All @@ -36,6 +41,10 @@ const (

sscTransportBrowser sscTransport = "browser"
sscTransportCapacitor sscTransport = "capacitor"

hostTransportWebSocket hostTransport = "websocket"
hostTransportStreamBus hostTransport = "streambus"
hostTransportAuto hostTransport = "auto"
)

// parseDelivery resolves the build.delivery value from rfw.json. An empty value
Expand Down Expand Up @@ -72,6 +81,22 @@ func parseSSCTransport(value string) (sscTransport, error) {
}
}

func parseHostTransport(value string) (hostTransport, error) {
switch transport := hostTransport(strings.ToLower(strings.TrimSpace(value))); transport {
case "", hostTransportWebSocket, "ws":
return hostTransportWebSocket, nil
case hostTransportStreamBus, "webtransport", "warp-streambus":
return hostTransportStreamBus, nil
case hostTransportAuto:
return hostTransportAuto, nil
default:
return "", fmt.Errorf(
"rfw.json: transport %q is not a host transport; use %q, %q or %q",
value, hostTransportWebSocket, hostTransportStreamBus, hostTransportAuto,
)
}
}

// buildShape is the delivery-relevant view of rfw.json. It exists so the two
// settings that shape a build stay separable: "static" decides whether the SSC
// client is linked, "embedded" decides how the bundle is delivered, and neither
Expand All @@ -81,6 +106,7 @@ type buildShape struct {
host string
delivery delivery
transport sscTransport
hostWire hostTransport
plugins map[string]json.RawMessage
}

Expand All @@ -102,7 +128,8 @@ func (s buildShape) compresses() bool {
// unknown delivery mode fails the build rather than quietly ignoring it.
func decodeBuildShape(data []byte) (buildShape, error) {
var manifest struct {
Build struct {
Transport string `json:"transport"`
Build struct {
Type string `json:"type"`
Host string `json:"host"`
Delivery string `json:"delivery"`
Expand All @@ -111,7 +138,7 @@ func decodeBuildShape(data []byte) (buildShape, error) {
Plugins map[string]json.RawMessage `json:"plugins"`
}
if err := json.Unmarshal(data, &manifest); err != nil {
return buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser}, nil
return buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser, hostWire: hostTransportWebSocket}, nil
}
mode, err := parseDelivery(manifest.Build.Delivery)
if err != nil {
Expand All @@ -121,11 +148,16 @@ func decodeBuildShape(data []byte) (buildShape, error) {
if err != nil {
return buildShape{}, err
}
hostWire, err := parseHostTransport(manifest.Transport)
if err != nil {
return buildShape{}, err
}
return buildShape{
static: manifest.Build.Type == "static",
host: manifest.Build.Host,
delivery: mode,
transport: transport,
hostWire: hostWire,
plugins: manifest.Plugins,
}, nil
}
18 changes: 16 additions & 2 deletions cmd/rfw/build/delivery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@ func TestDecodeBuildShapeKeepsDeliveryAndTypeSeparate(t *testing.T) {
{
name: "embedded SSC can select the Capacitor transport",
manifest: `{"build":{"type":"ssc","host":"wss://api.example.com/ws","delivery":"embedded","sscTransport":"capacitor"}}`,
want: buildShape{host: "wss://api.example.com/ws", delivery: deliveryEmbedded, transport: sscTransportCapacitor},
want: buildShape{host: "wss://api.example.com/ws", delivery: deliveryEmbedded, transport: sscTransportCapacitor, hostWire: hostTransportWebSocket},
},
{
name: "SSC can select StreamBus independently from its runtime",
manifest: `{"transport":"streambus","build":{"type":"ssc","host":"https://api.example.com"}}`,
want: buildShape{host: "https://api.example.com", delivery: deliveryNetwork, transport: sscTransportBrowser, hostWire: hostTransportStreamBus},
},
} {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -134,6 +139,9 @@ func TestDecodeBuildShapeKeepsDeliveryAndTypeSeparate(t *testing.T) {
if got.transport != tc.want.transport {
t.Fatalf("transport = %q, want %q", got.transport, tc.want.transport)
}
if got.hostWire != tc.want.hostWire && tc.want.hostWire != "" {
t.Fatalf("host wire transport = %q, want %q", got.hostWire, tc.want.hostWire)
}
})
}
}
Expand All @@ -150,14 +158,20 @@ func TestDecodeBuildShapeRejectsAnUnknownSSCTransport(t *testing.T) {
}
}

func TestDecodeBuildShapeRejectsAnUnknownHostTransport(t *testing.T) {
if _, err := decodeBuildShape([]byte(`{"transport":"telepathy"}`)); err == nil {
t.Fatal("an unknown host transport built without an error")
}
}

// An unreadable manifest has always fallen back to the defaults rather than
// failing the build, and it still does.
func TestDecodeBuildShapeToleratesAnUnparsableManifest(t *testing.T) {
got, err := decodeBuildShape([]byte("not json"))
if err != nil {
t.Fatalf("decodeBuildShape: %v", err)
}
if got.delivery != deliveryNetwork || got.transport != sscTransportBrowser || got.static || got.plugins != nil {
if got.delivery != deliveryNetwork || got.transport != sscTransportBrowser || got.hostWire != hostTransportWebSocket || got.static || got.plugins != nil {
t.Fatalf("shape = %+v, want the defaults", got)
}
}
Expand Down
1 change: 1 addition & 0 deletions cmd/rfw/initproj/template/rfw.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"transport": "websocket",
"build": {
"type": "ssc",
"host": ""
Expand Down
3 changes: 2 additions & 1 deletion docs/articles/guide/ssc.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

SSC splits a component in two: an HTML component that renders in the
browser, and a host component that runs in your Go server process. A
persistent WebSocket at `/ws` keeps them synchronized. The browser loads a
persistent transport keeps them synchronized. WebSocket remains available at
`/ws`, while Warp StreamBus can run over WebTransport at `/streambus`. The browser loads a
lightweight wasm binary that hydrates the HTML; your logic, queries and
secrets stay on the server. For the trust boundary and hardening options,
read the [SSC security model](ssc-security.md).
Expand Down
88 changes: 88 additions & 0 deletions docs/articles/guide/transports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# SSC transports

RFW keeps the SSC API independent from its network transport. Components,
`hostclient.Send`, typed actions, hydration, session resumption and broadcasts
behave the same with WebSocket and Warp StreamBus.

## Selecting StreamBus

Set the top-level `transport` key in `rfw.json`:

```json
{
"transport": "streambus",
"build": {
"type": "ssc",
"host": ""
}
}
```

The environment overrides the manifest:

```bash
RFW_TRANSPORT=streambus rfw dev
```

Accepted values are:

| Value | Behaviour |
| --- | --- |
| `websocket` | Use the existing `/ws` endpoint. This remains the default. |
| `streambus` | Prefer Warp StreamBus over WebTransport and fall back to WebSocket if the browser or network cannot establish it. |
| `auto` | Probe StreamBus first and transparently use WebSocket when unavailable. |

`webtransport` and `warp-streambus` are accepted aliases for `streambus`.

## Runtime model

StreamBus runs on WebTransport over HTTP/3 at `/streambus`. RFW preserves its
existing JSON SSC protocol and length-prefixes messages on a reliable QUIC
stream. On the server, Warp StreamBus provides bounded queues, priorities,
replay storage and explicit backpressure before frames reach the network.

The WebSocket endpoint remains mounted as a compatibility fallback. No
application code or component API changes when the selected transport changes.

Production deployments must route TCP and UDP for the HTTPS port and use a
certificate trusted by the browser. During `rfw dev`, the HTTP/3 endpoint uses
the HTTPS development port, which is the configured HTTP port plus one. RFW
automatically exposes the short-lived development certificate hash to the WASM
client so WebTransport can validate the self-signed certificate without an
application-level exception.

Set `window.RFW_STREAMBUS_URL` before loading the WASM module only when the
HTTP/3 endpoint uses a different public URL. Otherwise RFW derives it from
`build.host` or the page origin.

## Performance scope

The checked-in fan-out benchmark measures producer-side dispatch cost. It
compares StreamBus publication into bounded subscriber queues with RFW's
synchronous WebSocket fallback send path. Production WebSocket endpoints also
have an outbound queue, so this is deliberately not an end-to-end latency or
network-throughput claim.

Run it with:

```bash
go test ./host -run '^$' -bench 'Benchmark(StreamBus|WebSocket)Fanout' -benchmem -benchtime=2s -count=3
```

These numbers do not claim that every individual WebTransport round trip is
faster than every WebSocket round trip. They demonstrate the improvement for
the heavy fan-out and burst workloads StreamBus is designed to handle.

Reference run on Go 1.25.0 and an AMD EPYC 9V74, median of three 500 ms runs:

| Subscribers | StreamBus | WebSocket | Producer speedup |
| ---: | ---: | ---: | ---: |
| 1 | 878 ns/op | 4,483 ns/op | 5.1x |
| 10 | 10,505 ns/op | 47,526 ns/op | 4.5x |
| 100 | 115,784 ns/op | 507,769 ns/op | 4.4x |

At 100 subscribers, StreamBus used 28,801 B and 500 allocations per broadcast,
compared with 112,964 B and 1,801 allocations for synchronous WebSocket
fan-out. That is about 3.9x less allocated memory and 3.6x fewer allocations on
the producer path. The StreamBus benchmark includes the distinct SSC JSON
serialization and queue publish required for every target session.
1 change: 1 addition & 0 deletions examples/counter/rfw.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"transport": "websocket",
"build": {
"type": "static"
}
Expand Down
1 change: 1 addition & 0 deletions examples/dashboard/rfw.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"transport": "websocket",
"build": {
"type": "static"
}
Expand Down
Loading
Loading