# Architecture
This document explains how Uniview works under the hood - the custom reconcilers (React and Solid), RPC communication, and rendering pipeline.
High-Level Overview [#high-level-overview]
Data Flow [#data-flow]
Initialization [#initialization]
When a host loads a plugin:
State Update (User Interaction) [#state-update-user-interaction]
When a user interacts with the rendered UI:
**Full Tree Mode:**
**Incremental Mode:**
Props Update (Host-Initiated) [#props-update-host-initiated]
When the host updates plugin props:
Package Responsibilities [#package-responsibilities]
@uniview/protocol [#univiewprotocol]
The shared contract between plugin and host.
**Exports:**
* `UINode` - Serializable tree structure
* `Mutation` - Incremental update mutations (appendChild, removeChild, setText, etc.)
* `HostToPluginAPI` / `PluginToHostAPI` - RPC interfaces
* `LAYOUT_TAGS` - Built-in HTML elements
* `handlerIdProp()` - Handler ID convention
* Zod validators for runtime validation
```typescript
interface UINode {
id: string;
type: string; // "div", "Button", "Card", etc.
props: JSONValue; // Serializable props
children: (UINode | string)[];
}
```
@uniview/react-renderer [#univiewreact-renderer]
Custom React reconciler that produces serializable trees.
**Key Components:**
* **HostConfig** - React reconciler configuration
* **InternalNode** - In-memory tree representation
* **serializeTree()** - Converts to protocol UINode
* **MutationCollector** - Collects incremental mutations during commit
* **HandlerRegistry** - Maps functions to IDs
```typescript
import { render, serializeTree, HandlerRegistry } from "@uniview/react-renderer";
const registry = new HandlerRegistry();
const bridge = createRenderBridge();
bridge.subscribe((root) => {
const tree = serializeTree(root, registry);
// Send tree to host
});
render(, bridge);
```
@uniview/solid-renderer [#univiewsolid-renderer]
Solid universal renderer that produces the same serializable trees. Uses Babel with `babel-preset-solid` (`generate: "universal"`) to transform Solid JSX into renderer-agnostic calls.
**Key Components:**
* **createRenderer()** - Universal renderer (insert, spread, createElement)
* **SolidNode** - In-memory tree representation (same shape as InternalNode)
* **serializeTree()** - Converts to protocol UINode
* **SolidMutationCollector** - Collects incremental mutations
* **HandlerRegistry** - Maps functions to IDs
Both renderers produce identical `UINode` output — hosts don't need to know which framework the plugin uses.
Both reconcilers have no DOM dependencies. They work in Web Workers, Node.js,
Deno, and Bun.
@uniview/react-runtime & @uniview/solid-runtime [#univiewreact-runtime--univiewsolid-runtime]
Plugin bootstrap and lifecycle management. Both packages follow the same API pattern.
**React (`@uniview/react-runtime`):**
```typescript
import { startWorkerPlugin } from "@uniview/react-runtime";
startWorkerPlugin({ App });
```
**Solid (`@uniview/solid-runtime`):**
```typescript
import { startSolidWorkerPlugin } from "@uniview/solid-runtime";
startSolidWorkerPlugin({ App });
```
Both runtimes also provide WebSocket client entries for server-side plugins via `/ws-client` subpath exports.
@uniview/host-sdk [#univiewhost-sdk]
Framework-agnostic host infrastructure.
**Exports:**
* `PluginController` - Unified interface for all modes
* `createWorkerController()` - Web Worker mode
* `createWebSocketController()` - WebSocket mode
* `createMainController()` - Main thread mode
* `createComponentRegistry()` - Custom component mapping
* `MutableTree` - Applies incremental mutations to UINode tree
```typescript
const controller = createWorkerController({
pluginUrl: "/plugin.js",
initialProps: { theme: "dark" },
});
controller.subscribe((tree) => {
// Render tree
});
await controller.connect();
```
@uniview/host-svelte [#univiewhost-svelte]
Svelte 5 rendering adapter.
**Components:**
* `PluginHost` - Main lifecycle component
* `ComponentRenderer` - Recursive tree renderer
```svelte
{#snippet loading()}
Loading...
{/snippet}
```
Key Design Decisions [#key-design-decisions]
1. Protocol is Product-Agnostic [#1-protocol-is-product-agnostic]
The protocol defines `UINode` with `type: string`. No hardcoded component types.
```typescript
// Protocol doesn't know about "Button" or "Card"
interface UINode {
type: string; // Any string - products define their own
// ...
}
```
Products register their own components:
```typescript
registry.register("Button", MyButton);
registry.register("Card", MyCard);
registry.register("CustomWidget", MyWidget);
```
2. Layout Tags as Primitives [#2-layout-tags-as-primitives]
HTML-like elements are always available:
```typescript
const LAYOUT_TAGS = [
"div",
"span",
"p",
"section",
"header",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"br",
"hr",
"button",
"input",
"form",
"label",
"a",
// ...
];
```
Hosts render these as native elements without registration.
3. Handler ID Convention [#3-handler-id-convention]
Functions can't cross RPC boundaries. The convention:
1. Plugin registers handler: `onClick={() => ...}` → ID `"h_abc123"`
2. Serialization: `{ onClick: fn }` → `{ _onClickHandlerId: "h_abc123" }`
3. Host creates proxy: `onclick: () => controller.execute("h_abc123")`
```typescript
// Protocol helpers
import {
handlerIdProp,
isHandlerIdProp,
extractEventName,
} from "@uniview/protocol";
handlerIdProp("onClick"); // "_onClickHandlerId"
isHandlerIdProp("_onClickHandlerId"); // true
extractEventName("_onClickHandlerId"); // "onClick"
```
4. Worker-First Architecture [#4-worker-first-architecture]
Primary mode is Web Worker for security:
* Plugin code runs in sandboxed Worker
* No direct DOM access
* Communication only via structured RPC
* Host controls what gets rendered
5. Thin Framework Adapters [#5-thin-framework-adapters]
All controller logic lives in `host-sdk`. Adapters only handle rendering:
```
host-sdk (400 lines) ← All the logic
host-svelte (150 lines) ← Just Svelte rendering
host-vue (150 lines) ← Just Vue rendering
```
Bridge Architecture (WebSocket Mode) [#bridge-architecture-websocket-mode]
In WebSocket mode, Uniview uses a **Bridge Server** pattern to facilitate communication between the browser host and the plugin running in a server-side environment (Node.js, Deno, Bun).
The Bridge Pattern [#the-bridge-pattern]
Unlike the traditional approach where each plugin runs its own WebSocket server, Uniview uses a single Elysia-based bridge server (provided by `@uniview/bridge-server`). This simplifies deployment and networking by multiplexing all plugin connections through a single port.
Key Characteristics [#key-characteristics]
* **Plugins as Clients**: Plugins connect **as clients** to the bridge server. They do not need to manage their own server lifecycle or open public ports.
* **Transparent Byte Forwarding**: The bridge server does not parse or inspect the RPC messages. It simply forwards raw bytes between the matched host and plugin pairs based on the `pluginId`.
* **Simplified Deployment**: Only the bridge server needs a stable, reachable address. Plugins can run behind NAT or in ephemeral environments as long as they can reach the bridge.
* **Unified Port**: All communication (multiple plugins, multiple hosts) happens over a single port (default `:3000`), making it easier to configure firewalls and reverse proxies.
Security Model [#security-model]
WebSocket mode and Main Thread mode do NOT provide sandbox isolation. Use
Worker mode for untrusted plugins.
Extension Points [#extension-points]
Custom Components [#custom-components]
Define product-specific primitives:
```typescript
// Your product's API
registry.register("CommandPalette", CommandPalette);
registry.register("FileTree", FileTree);
registry.register("Terminal", Terminal);
```
Custom Transports [#custom-transports]
Support additional communication channels:
```typescript
// Built-in
createWorkerController({ pluginUrl });
createWebSocketController({ serverUrl });
// Potential extensions
createIframeController({ frameUrl });
createElectronController({ processId });
```
Custom APIs [#custom-apis]
Expose additional functionality to plugins via RPC extensions:
```typescript
// Host exposes file system API
controller.expose({
readFile: (path) => fs.readFileSync(path),
writeFile: (path, data) => fs.writeFileSync(path, data),
});
// Plugin uses it
const content = await hostApi.readFile("/config.json");
```
# Getting Started
This guide walks you through creating a simple interactive plugin and rendering it in a Svelte host.
Prerequisites [#prerequisites]
* Node.js 18+
* pnpm (recommended) or npm
Installation [#installation]
Install the core packages:
```bash
# For React plugin development
pnpm add @uniview/react-runtime @uniview/react-renderer @uniview/protocol react
# For Solid plugin development
pnpm add @uniview/solid-runtime @uniview/solid-renderer @uniview/protocol solid-js
# For host development (Svelte)
pnpm add @uniview/host-sdk @uniview/host-svelte @uniview/protocol
```
Creating a Plugin [#creating-a-plugin]
Plugins can be written in **React** or **Solid**. This guide shows the React path — see below for Solid.
1. Write Your React Component [#1-write-your-react-component]
Create a React component. Plugins can use standard React hooks, state, and standard HTML elements.
```tsx title="src/App.tsx"
import { useState } from "react";
export default function App() {
const [name, setName] = useState("");
const [submitted, setSubmitted] = useState(false);
// Conditional rendering
if (submitted) {
return (
);
}
```
2. Create the Worker Entry Point [#2-create-the-worker-entry-point]
Bootstrap your plugin using the runtime:
```typescript title="src/worker.ts"
import { startWorkerPlugin } from "@uniview/react-runtime";
import App from "./App";
startWorkerPlugin({ App });
```
3. Build the Plugin [#3-build-the-plugin]
Bundle for browser (Web Worker target):
```typescript title="build.ts"
// Using Bun
await Bun.build({
entrypoints: ["./src/worker.ts"],
outdir: "./dist",
target: "browser",
format: "esm",
minify: true,
});
```
Or with other bundlers (esbuild, Vite, etc.):
```bash
esbuild src/worker.ts --bundle --format=esm --outfile=dist/plugin.js
```
The plugin bundle must be self-contained with all dependencies included. Web
Workers can't access the host's modules.
Creating a Solid Plugin [#creating-a-solid-plugin]
The same steps apply for Solid, with different packages and a required build step:
```tsx title="src/App.tsx"
import { createSignal } from "solid-js";
const App = () => {
const [count, setCount] = createSignal(0);
return (
Count: {count()}
);
};
export default App;
```
```typescript title="src/worker.ts"
import { startSolidWorkerPlugin } from "@uniview/solid-runtime";
import App from "./App";
startSolidWorkerPlugin({ App });
```
Solid plugins require a Babel build step with `babel-preset-solid` (`generate: "universal"`) because Bun/Node can't natively run Solid's JSX transform. See `examples/plugin-solid-example/build.ts` for the full build configuration.
When building Solid plugins for Bun/Node.js (`target: "bun"`), you must add
`conditions: ["browser"]` to the build config. Without this, Bun resolves
`solid-js` to its SSR build which has no reactivity.
Creating a Host (Svelte) [#creating-a-host-svelte]
1. Set Up the Controller [#1-set-up-the-controller]
```svelte title="src/routes/+page.svelte"
Uniview Host Demo
{#snippet loading()}
Loading plugin environment...
{/snippet}
```
2. Serve the Plugin [#2-serve-the-plugin]
Place your built plugin in `static/plugins/` (for SvelteKit) or configure your dev server to serve it.
3. Run the App [#3-run-the-app]
```bash
pnpm dev
```
Your plugin should now render inside the host!
Custom Components [#custom-components]
By default, plugins can use HTML elements (`div`, `button`, `input`, etc.). For custom components:
Plugin Side [#plugin-side]
Create wrapper components that emit custom types:
```tsx title="plugin-api/Button.tsx"
import { createElement } from "react";
export function Button({ title, onClick, variant = "primary" }) {
return createElement("Button", { title, onClick, variant });
}
```
Host Side [#host-side]
Register renderers for custom types:
```svelte title="components/PluginButton.svelte"
```
```typescript
// In your page
registry.register("Button", PluginButton);
```
Runtime Modes [#runtime-modes]
Uniview supports three runtime modes:
| Mode | Use Case | Security |
| --------------- | --------------------------------- | ---------------- |
| **Worker** | Production, untrusted plugins | Sandboxed |
| **WebSocket** | Server-side plugins, Node.js APIs | Network isolated |
| **Main Thread** | Development, debugging | None |
For complex setups involving multiple modes, check out the [Advanced Host Guide](/docs/guides/advanced-host).
Next Steps [#next-steps]
Build a production-ready host with mode switching and dynamic loading
Set up the WebSocket bridge for Node.js/server-side plugins
Learn how to create and register custom components
# Uniview
Uniview is a universal plugin system that enables writing plugins in **React or Solid** that can be rendered by **any host framework** - Svelte, Vue, React, or custom implementations.
Why Uniview? [#why-uniview]
Modern applications often need extensibility through plugins. Uniview solves the challenge of:
* **Framework Lock-in**: Write plugins once, render anywhere
* **Security**: Sandboxed execution in Web Workers
* **Flexibility**: Run plugins in browser, Node.js, Deno, or Bun
* **Type Safety**: Full TypeScript support with RPC type checking
Web Worker] <-->|RPC kkrpc UINode tree| B[Host Svelte or Vue, React]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#10b981,stroke:#047857,color:#fff
`}
/>
```
RPC (kkrpc)
┌───────────────────────┐ ◄──────────────────► ┌──────────────────┐
│ Plugin (React/Solid) │ UINode tree │ Host (Svelte) │
│ Web Worker │ │ or Vue, React │
└───────────────────────┘ └──────────────────┘
```
Key Features [#key-features]
Write plugins using React or Solid - hooks, signals, state, components
Render in Svelte, Vue, React, or build your own adapter
Run untrusted plugins safely in Web Workers
Execute plugins in Node.js/Deno via WebSocket
Render plugins to terminal without DOM (like React Native)
Render plugins as native SwiftUI or AppKit applications
Quick Example [#quick-example]
```tsx
// App.tsx
import { useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
return (
);
};
export default App;
```
**Host (Svelte):**
```svelte
```
Packages [#packages]
| Package | Description |
| ------------------------- | ----------------------------------------- |
| `@uniview/protocol` | Core types, UINode schema, RPC interfaces |
| `@uniview/react-renderer` | Custom React reconciler |
| `@uniview/solid-renderer` | Solid universal renderer |
| `@uniview/react-runtime` | React plugin bootstrap (Worker/Node.js) |
| `@uniview/solid-runtime` | Solid plugin bootstrap (Worker/Node.js) |
| `@uniview/host-sdk` | Framework-agnostic host controller |
| `@uniview/host-svelte` | Svelte 5 rendering adapter |
| `@uniview/tui-renderer` | Terminal UI renderer (non-DOM) |
Inspiration [#inspiration]
Uniview uses custom reconcilers (React and Solid) to serialize component trees into a framework-agnostic UINode format, enabling plugins to render on any host platform.
Build your first plugin in 5 minutes
Understand how Uniview works
# Advanced Host Implementation
This guide demonstrates how to build a robust Svelte host that can dynamically switch between Worker, WebSocket, and Main Thread modes. This pattern is useful for building "Universal" applications that support both local and remote plugins.
The Universal Host Pattern [#the-universal-host-pattern]
A production-ready host should handle:
1. **Dynamic Controller Creation**: Creating the right controller (`Worker`, `WebSocket`, `Main`) based on configuration.
2. **Resource Cleanup**: Disconnecting controllers when switching modes or unmounting.
3. **Loading States**: Showing feedback while the plugin initializes.
4. **Error Handling**: Gracefully handling connection failures.
Implementation (Svelte 5) [#implementation-svelte-5]
Here is a complete example of a host page that supports all three modes.
```svelte title="src/routes/+page.svelte"
```
Key Concepts [#key-concepts]
Derived Controller Creation [#derived-controller-creation]
We use `$derived.by` to reactively recreate the controller whenever `runtimeMode` changes. This ensures:
1. The old controller is properly cleaned up (via `$effect`).
2. The new controller is initialized with the correct configuration.
3. The `registry` is re-instantiated (or shared, depending on your needs).
Keyed Re-rendering [#keyed-re-rendering]
Wrapping `` in a `{#key runtimeMode}` block forces Svelte to destroy and recreate the host component when the mode changes. This is crucial because `PluginHost` maintains internal subscription state that needs to be reset for a fresh connection.
```svelte
{#key runtimeMode}
{/key}
```
Loading States [#loading-states]
The `{#snippet loading()}` block allows you to provide a custom UI while the plugin is initializing. This is especially important for:
* **Worker Mode**: Script loading and worker startup (approx. 50-200ms).
* **WebSocket Mode**: Network connection latency (10-100ms+).
Connection Lifecycle [#connection-lifecycle]
1. **Mount**: Host connects to the controller (`controller.connect()`).
2. **Handshake**: Controller establishes RPC link with plugin.
3. **Render**: Plugin sends initial tree → Host renders.
4. **Update**: User interaction → Host sends event → Plugin updates tree → Host re-renders.
5. **Unmount**: Host disconnects (`controller.disconnect()`), closing workers/sockets.
# Benchmark
The benchmark plugin helps you measure and compare performance between full-tree and incremental update modes. It's useful for understanding the trade-offs and validating performance improvements.
Running the Benchmark [#running-the-benchmark]
Quick Start [#quick-start]
```bash
# Start the full demo environment
cd examples/host-svelte-demo
pnpm dev:all
# Open benchmark with incremental mode
open http://localhost:5173?demo=benchmark&update=incremental
# Or full-tree mode for comparison
open http://localhost:5173?demo=benchmark&update=full
```
URL Parameters [#url-parameters]
The benchmark supports query parameters for quick access:
| Parameter | Values | Description |
| ----------- | ------------------------- | --------------------- |
| `demo` | `benchmark` | Select benchmark demo |
| `update` | `full` \| `incremental` | Update mode |
| `framework` | `react` \| `solid` | Plugin framework |
| `runtime` | `worker` \| `node-server` | Runtime mode |
Example: `http://localhost:5173?demo=benchmark&framework=react&update=incremental&runtime=worker`
Benchmark Configuration [#benchmark-configuration]
Test Load [#test-load]
The benchmark initializes with **1000 items** and allows up to **2000 items maximum**. Each item contains:
* Unique ID
* 20 words of lorem ipsum text (\~100-150 characters)
This provides sufficient stress for modern computers while remaining responsive.
Operations [#operations]
**Manual Operations:**
* **Add 10 Items** - Appends 10 new items to the list
* **Remove 10 Items** - Removes last 10 items
* **Update All Texts** - Changes text on all items (stress test)
* **Update Single Item** - Modifies one random item
**Auto-Benchmark:**
* Runs 50 cycles alternating between add and modify
* Auto-stops at 500 cycles or when reaching max items
* Measures sustained performance under load
Metrics Explained [#metrics-explained]
Operation Metrics (Per Click) [#operation-metrics-per-click]
These metrics measure performance from the user's perspective (each button click):
| Metric | Description | Typical Values |
| -------------------- | ------------------------------ | --------------------------- |
| Operations performed | Total button clicks | User-dependent |
| Last operation | Time for most recent operation | 5-15ms |
| Avg time/operation | Mean operation latency | 6-10ms |
| Messages in last op | RPC messages sent | 1-5 (incremental), 1 (full) |
| Avg messages/op | Mean messages per operation | 1.5-3 (incremental) |
| Avg bytes/op | Mean bandwidth per operation | 50-200KB |
Message Metrics (Per Message) [#message-metrics-per-message]
These metrics measure the RPC communication efficiency:
| Metric | Description | Full Tree | Incremental |
| -------------- | ---------------------- | --------- | ----------- |
| Total messages | Cumulative RPC calls | Lower | Higher |
| Total bytes | Cumulative bandwidth | Higher | Lower\* |
| Bytes/message | Average message size | \~87KB | \~69KB |
| Time/message | Serialization overhead | \~0.1μs | \~0.08μs |
\*Note: Incremental mode may show higher total bytes in some scenarios due to message overhead. This is more noticeable with small trees.
Performance Comparison [#performance-comparison]
Typical Results (1000 items, React) [#typical-results-1000-items-react]
**Full Tree Mode:**
```
Last operation: 8.10ms
Total messages: 101
Total bytes: 8.76 MB
Bytes/message: 86,697
Avg messages/op: 1.0
```
**Incremental Mode:**
```
Last operation: 6.30ms
Total messages: 202
Total bytes: 13.95 MB
Bytes/message: 69,081
Avg messages/op: 2.0
```
Analysis [#analysis]
**When Incremental is Better:**
* Large lists with frequent small updates
* Adding/removing individual items
* Text updates on existing nodes
* Bandwidth-constrained environments
**When Full Tree is Better:**
* Small trees (\< 100 items)
* Complete re-renders (theming, layout changes)
* Simple apps with minimal updates
* When message overhead exceeds payload savings
How Incremental Updates Work [#how-incremental-updates-work]
Mutation Types [#mutation-types]
Instead of sending the entire tree, incremental mode sends mutations:
```typescript
// Append new child
type AppendChildMutation = {
type: "appendChild";
parentId: string;
node: UINode;
};
// Update text content
type SetTextMutation = {
type: "setText";
textNodeId: string;
text: string;
};
// Update props
type SetPropsMutation = {
type: "setProps";
nodeId: string;
props: Record;
};
```
Collection Process [#collection-process]
1. **React/Solid Reconciler** detects changes during render
2. **MutationCollector** records each change type
3. **End of commit** flushes mutations to RPC
4. **Host applies** mutations to existing tree
Host-Side Application [#host-side-application]
The host maintains a `MutableTree` that applies mutations:
```typescript
const mutableTree = new MutableTree();
// On full tree update
mutableTree.init(newTree);
// On incremental update
mutableTree.applyMutations(mutations);
```
The tree uses indexing for O(1) lookups:
* `nodeIndex: Map` - Element lookups
* `textIndex: Map` - Text node tracking
Best Practices [#best-practices]
Choosing Update Mode [#choosing-update-mode]
**Use Incremental When:**
* List has 100+ items
* Frequent individual item updates
* Network bandwidth is limited
* Latency matters (real-time apps)
**Use Full Tree When:**
* Simple forms or small UIs
* Complete re-renders are common
* Debugging rendering issues
* Plugin doesn't support incremental
Optimizing Performance [#optimizing-performance]
1. **Batch Updates**: Group related state changes to reduce mutation count
2. **Stable IDs**: Ensure list items have stable keys for efficient diffing
3. **Avoid Deep Nesting**: Flatten tree structure where possible
4. **Measure First**: Use benchmark to validate assumptions
Debugging [#debugging]
**High message count?**
* Check for unstable component keys
* Look for unnecessary re-renders
* Consider batching state updates
**Large message sizes?**
* Reduce prop payload sizes
* Avoid passing large objects
* Use references/IDs instead of data
Implementation Details [#implementation-details]
Plugin Side [#plugin-side]
```typescript
// Enable incremental mode
import { startWorkerPlugin } from "@uniview/react-runtime";
startWorkerPlugin({
App: MyBenchmarkApp,
mode: "incremental", // "full" | "incremental"
});
```
Host Side [#host-side]
Incremental mode is transparent to hosts. The controller handles both modes:
```typescript
const controller = createWorkerController({
pluginUrl: "/plugins/benchmark-incremental.worker.js",
});
// Works with both full and incremental plugins
controller.subscribe((tree) => renderTree(tree));
```
Future Improvements [#future-improvements]
* **Batching**: Group rapid mutations into single RPC call
* **Compression**: Delta compression for text updates
* **Virtual Scrolling**: Render only visible items
* **Memory Profiling**: Track heap usage during benchmarks
# Bridge Server Setup
To run plugins on the server (Node.js, Deno, or Bun), Uniview uses a **Bridge Server**. This server acts as a transparent WebSocket multiplexer, allowing multiple plugins and hosts to communicate over a single port.
Why a Bridge Server? [#why-a-bridge-server]
1. **Single Port**: All plugins connect to one port (e.g., `:3000`). You don't need to open 50 ports for 50 plugins.
2. **No NAT Traversal**: Plugins connect *outbound* to the bridge. They can run behind firewalls or inside Docker containers without exposing ports.
3. **Protocol Agnostic**: The bridge just forwards raw messages. It doesn't need to know about Uniview protocols or updates.
Implementation (Elysia) [#implementation-elysia]
We recommend using [Elysia](https://elysiajs.com/) (on Bun) for the bridge server due to its performance and simplicity, but any WebSocket server will work.
Here is the complete code for a production-ready bridge server:
```typescript title="server/index.ts"
import { Elysia } from "elysia";
import { readFile } from "fs/promises";
import { join } from "path";
// In-memory connection map
// pluginId -> { pluginWs, hostWs }
const connections = new Map();
function normalizeMessage(message: unknown): string {
let msgStr =
typeof message === "string"
? message
: message instanceof Buffer
? message.toString()
: JSON.stringify(message);
return msgStr;
}
const app = new Elysia()
// Optional: Serve static worker files (if you want to host worker bundles too)
.get("/:filename", async ({ params }) => {
try {
const file = await readFile(join("./dist", params.filename));
return new Response(file, {
headers: { "Content-Type": "application/javascript" },
});
} catch {
return new Response("Not found", { status: 404 });
}
})
// 1. Plugin Endpoint: /plugins/:pluginId
.ws("/plugins/:pluginId", {
open(ws) {
const pluginId = ws.data.params.pluginId;
console.log(`[Bridge] Plugin connected: ${pluginId}`);
if (!connections.has(pluginId)) {
connections.set(pluginId, {});
}
connections.get(pluginId)!.pluginWs = ws;
},
message(ws, message) {
const pluginId = ws.data.params.pluginId;
const conn = connections.get(pluginId);
// Forward message to Host
if (conn?.hostWs) {
conn.hostWs.send(normalizeMessage(message));
}
},
close(ws) {
const pluginId = ws.data.params.pluginId;
const conn = connections.get(pluginId);
if (conn) {
conn.pluginWs = undefined;
if (!conn.hostWs) connections.delete(pluginId);
}
console.log(`[Bridge] Plugin disconnected: ${pluginId}`);
},
})
// 2. Host Endpoint: /host/:pluginId
.ws("/host/:pluginId", {
open(ws) {
const pluginId = ws.data.params.pluginId;
const conn = connections.get(pluginId);
// Require plugin to be ready first
if (!conn?.pluginWs) {
ws.close(1000, "Plugin not ready");
return;
}
// Replace existing host connection if any
if (conn.hostWs) {
conn.hostWs.close(1000, "Replaced by new connection");
}
conn.hostWs = ws;
console.log(`[Bridge] Host connected: ${pluginId}`);
},
message(ws, message) {
const pluginId = ws.data.params.pluginId;
const conn = connections.get(pluginId);
// Forward message to Plugin
if (conn?.pluginWs) {
conn.pluginWs.send(normalizeMessage(message));
}
},
close(ws) {
const pluginId = ws.data.params.pluginId;
const conn = connections.get(pluginId);
if (conn) {
conn.hostWs = undefined;
if (!conn.pluginWs) connections.delete(pluginId);
}
console.log(`[Bridge] Host disconnected: ${pluginId}`);
},
})
.listen(3000);
console.log(
`🌉 Bridge server listening on ${app.server?.hostname}:${app.server?.port}`,
);
```
Running the Server [#running-the-server]
Using Bun:
```bash
bun run server/index.ts
```
Connecting to the Bridge [#connecting-to-the-bridge]
Plugin Side [#plugin-side]
Connect your Node.js/Deno/Bun plugin to the bridge:
```typescript
import { connectToHostServer } from "@uniview/react-runtime/ws-client";
import App from "./App";
connectToHostServer({
App,
serverUrl: "ws://localhost:3000",
pluginId: "my-plugin", // Must match unique ID
});
```
Host Side [#host-side]
Connect your browser host to the bridge:
```typescript
import { createWebSocketController } from "@uniview/host-sdk";
const controller = createWebSocketController({
serverUrl: "ws://localhost:3000",
pluginId: "my-plugin", // Must match plugin's ID
});
```
# Custom Components
Learn how to create custom components that plugins can use, giving you full control over styling and behavior.
Overview [#overview]
Uniview has two types of renderable elements:
1. **Layout Tags**: Built-in HTML elements (`div`, `button`, `input`, etc.)
2. **Custom Components**: Your framework-specific implementations
When a plugin renders `