Hypen
Getting Started

Building a Remote App

Build a cross-platform counter app that runs on a server and streams to Web and Android clients

Building a Remote App

In this tutorial, you'll build a cross-platform counter app that runs on a server and streams to Web and Android clients. This is the recommended approach for production Hypen apps.

Why Remote Apps?

With remote apps, your server runs the WASM engine and streams UI patches to clients:

┌─────────────────────────────────────────────────────┐
│  Your Server (Bun/Node.js)                          │
│  • Runs Hypen WASM engine                           │
│  • Manages state and business logic                 │
│  • Streams UI patches over WebSocket                │
└─────────────────────────────────────────────────────┘
                      │ WebSocket
        ┌─────────────┼─────────────┐
        ↓             ↓             ↓
   ┌─────────┐   ┌─────────┐   ┌─────────┐
   │   Web   │   │ Android │   │   iOS   │
   │ Browser │   │   App   │   │   App   │
   └─────────┘   └─────────┘   └─────────┘

Benefits:

  • One codebase - Write once, run on all platforms
  • Thin clients - Clients just render patches, no WASM needed
  • Server-side state - Easy to persist, sync, and secure
  • Hot updates - Change UI without app store updates

What We're Building

The same counter from the first app tutorial, but streaming to any client:

┌─────────────────────────┐
│                         │
│       Count: 0          │
│                         │
│    [ - ]     [ + ]      │
│                         │
└─────────────────────────┘

Step 1: Create the Server

Create a new project:

mkdir counter-server
cd counter-server
bun init -y
bun add @hypen-space/core

Create server.ts:

import { RemoteServer, app } from "@hypen-space/core";

// 1. Define the state
interface CounterState {
  count: number;
}

// 2. Create the module
const counterModule = app
  .defineState<CounterState>({ count: 0 })
  .onAction("increment", async ({ state }) => {
    state.count++;
  })
  .onAction("decrement", async ({ state }) => {
    state.count--;
  })
  .onAction("reset", async ({ state }) => {
    state.count = 0;
  })
  .build();

// 3. Define the UI
const ui = `
  Column {
    Text("Count: \${state.count}")
      .fontSize(48)
      .fontWeight("bold")
      .color("#1F2937")

    Row {
      Button {
        Text("-")
          .fontSize(24)
          .color("#FFFFFF")
      }
      .onClick(@actions.decrement)
      .backgroundColor("#EF4444")
      .padding(16)
      .borderRadius(8)

      Spacer()
        .width(24)

      Button {
        Text("Reset")
          .color("#FFFFFF")
      }
      .onClick(@actions.reset)
      .backgroundColor("#6B7280")
      .padding(16)
      .borderRadius(8)

      Spacer()
        .width(24)

      Button {
        Text("+")
          .fontSize(24)
          .color("#FFFFFF")
      }
      .onClick(@actions.increment)
      .backgroundColor("#22C55E")
      .padding(16)
      .borderRadius(8)
    }
  }
  .padding(32)
  .gap(24)
`;

// 4. Start the server
new RemoteServer()
  .module("Counter", counterModule)
  .ui(ui)
  .onConnection((client) => {
    console.log(`Client connected: ${client.id}`);
  })
  .onDisconnection((client) => {
    console.log(`Client disconnected: ${client.id}`);
  })
  .listen(3000);

console.log("Counter server running on ws://localhost:3000");

Run the server:

bun run server.ts

Step 2: Connect a Web Client

Option A: Using the HypenApp Component

The easiest way is to use the HypenApp component directly in your Hypen DSL:

Column {
  Text("My Counter App")
    .fontSize(24)

  HypenApp("ws://localhost:3000")
}

Option B: Programmatic Connection

For more control, use RemoteEngine directly:

Create client.html:

<!DOCTYPE html>
<html>
<head>
  <title>Counter Client</title>
  <script type="module">
    import { RemoteEngine } from "https://unpkg.com/@hypen-space/core/dist/remote/client.js";

    const container = document.getElementById("app");
    const engine = new RemoteEngine("ws://localhost:3000");

    // Simple patch renderer
    const nodes = new Map();

    engine.onPatches((patches) => {
      for (const patch of patches) {
        // Handle each patch type...
        console.log("Patch:", patch);
      }
    });

    engine.connect();
  </script>
</head>
<body>
  <div id="app"></div>
</body>
</html>

Or with the full DOM renderer:

import { RemoteEngine } from "@hypen-space/core";
import { DOMRenderer } from "@hypen-space/web";

async function main() {
  const container = document.getElementById("app")!;
  const renderer = new DOMRenderer(container);
  const engine = new RemoteEngine("ws://localhost:3000");

  engine.onPatches((patches) => {
    renderer.applyPatches(patches);
  });

  await engine.connect();
  console.log("Connected to server!");
}

main();

Step 3: Connect an Android Client

In your Android project, add the Hypen dependency:

// build.gradle.kts
dependencies {
    implementation("com.hypenspace:renderer:0.1.0")
}

In your Composable:

import com.hypenspace.renderer.HypenApp

@Composable
fun CounterScreen() {
    // For emulator, use 10.0.2.2 to reach host machine
    // For physical device, use your machine's IP address
    HypenApp(
        url = "ws://10.0.2.2:3000",
        modifier = Modifier.fillMaxSize()
    )
}

That's it! The Android app will connect to your server and render the counter.

How It Works

  1. Client connects - WebSocket connection to server
  2. Server sends initial tree - Full UI tree as patches
  3. Client renders - Applies patches to native UI (DOM, Compose, etc.)
  4. User interacts - Clicks a button
  5. Client sends action - { type: "dispatchAction", action: "increment" }
  6. Server updates state - state.count++
  7. Server sends patch - Only the changed text node
  8. Client updates - Applies minimal patch to UI

Server API Reference

RemoteServer

new RemoteServer()
  .module(name, module)     // Set the module
  .ui(dslString)            // Set the UI template
  .config({                 // Optional configuration
    port: 3000,
    hostname: "0.0.0.0"
  })
  .onConnection(callback)   // Called when client connects
  .onDisconnection(callback)// Called when client disconnects
  .listen(port?)            // Start the server

serve() Helper

For simpler cases:

import { serve, app } from "@hypen-space/core";

serve({
  module: myModule,
  ui: myUI,
  port: 3000,
  onConnection: (client) => console.log("Connected:", client.id),
});

Client API Reference

HypenApp (Web)

In Hypen DSL:

HypenApp("ws://localhost:3000")

// Or with options:
HypenApp(
  url: "ws://localhost:3000",
  autoReconnect: true,
  reconnectInterval: 3000
)

HypenApp (Android)

@Composable
fun HypenApp(
    url: String,
    modifier: Modifier = Modifier,
    config: RemoteEngineConfig = RemoteEngineConfig.DEFAULT,
    loadingContent: @Composable () -> Unit = { DefaultLoading() },
    errorContent: @Composable (String) -> Unit = { DefaultError(it) }
)

RemoteEngine (Programmatic)

const engine = new RemoteEngine(url, {
  autoReconnect: true,        // Auto-reconnect on disconnect
  reconnectInterval: 3000,    // Wait 3s before reconnecting
  maxReconnectAttempts: 10    // Give up after 10 attempts
});

engine.onPatches((patches) => { /* handle patches */ });
engine.onStateUpdate((state) => { /* handle state */ });
engine.onConnect(() => { /* connected */ });
engine.onDisconnect(() => { /* disconnected */ });
engine.onError((error) => { /* handle error */ });

await engine.connect();
engine.dispatchAction("increment", { amount: 5 });
engine.disconnect();

Production Considerations

Scaling

Each client gets its own module instance. For shared state across clients:

// Shared state outside module
const globalState = { viewers: 0 };

const module = app
  .defineState({ count: 0 })
  .onCreated(async ({ state }) => {
    globalState.viewers++;
    // Broadcast to other clients...
  })
  .build();

Security

Add authentication before the WebSocket upgrade:

// Custom server with auth
Bun.serve({
  port: 3000,
  fetch(req, server) {
    const token = req.headers.get("Authorization");
    if (!validateToken(token)) {
      return new Response("Unauthorized", { status: 401 });
    }
    server.upgrade(req);
  },
  websocket: {
    // ... RemoteServer handlers
  }
});

Deployment

Deploy your server to any platform that supports WebSockets:

  • Fly.io
  • Railway
  • Render
  • AWS/GCP/Azure
  • Your own VPS

Next Steps