Skip to content

developit/mixed-signals

Repository files navigation

mixed-signals

RPC + reflection for Preact Signals and Models: access reactive model state and methods from a server (or worker/tab/etc) as if they lived on the client. Type-safe, minimal magic, and an optimized transport-agnostic protocol (WebSocket, SSE, postMessage, etc).

Installation:

npm install mixed-signals

The only dependency is @preact/signals-core (>=1.8.0).

How it works

mixed-signals reflects server-side Preact Models and Signals (anything created via @preact/signals-core) to connected clients in real-time. Signals on the server are serialized with identity markers, and the client reconstructs them as local signals that stay in sync via a lightweight wire protocol.

  • Server models use createModel() from signal-wire/server (a thin wrapper around @preact/signals-core's createModel)
  • Client models use createReflectedModel() from signal-wire/client to create local proxies that mirror server state
  • An RPC layer handles method calls (client → server) and signal updates (server → client)
  • Delta compression for arrays (append), objects (merge), and strings (append) minimizes bandwidth

Full Example

server.ts

import { WebSocketServer } from "ws";
import { signal } from "@preact/signals-core";
import { RPC, createModel } from "mixed-signals/server";

const Todo = createModel((_text = "") => {
  const text = signal(_text);
  const done = signal(false);
  const toggle = () => done.value = !done.value;
  return { text, done, toggle };
});
type Todo = InstanceType<typeof Todo>;

const Todos = createModel(() => {
  const all = signal<Todo[]>([]);
  function add(text: string) {
    const todo = new Todo(text);
    all.value = [...all.value, todo];
    return todo;
  }
  return { all, add };
});
type Todos = InstanceType<typeof Todos>;

const todos = new Todos();
const rpc = new RPC({ todos });
rpc.registerModel("Todo", Todo);
rpc.registerModel("Todos", Todos);

const wss = new WebSocketServer();
wss.on("connection", (ws) => {
  const dispose = rpc.addClient({
    send: ws.send.bind(ws),
    onMessage: ws.on.bind(ws, "message"),
  });
  ws.on("close", dispose);
});

client.tsx

import { useSignal } from "@preact/signals";
import { RPCClient, createReflectedModel } from "mixed-signals/client";
import type { Todo, Todos } from "./server.ts";

const TodoModel = createReflectedModel<Todo>(["text", "done"], ["toggle"]);
const TodosModel = createReflectedModel<Todos>(["all"], ["add"]);

const ws = new WebSocket("/rpc");
const rpc = new RPCClient({
  send: ws.send.bind(ws),
  onMessage: ws.addEventListener.bind(ws, "message"),
  ready: new Promise((r) => ws.addEventListener("open", r, { once: true })),
}, {});
rpc.registerModel("Todo", TodoModel);
rpc.registerModel("Todos", TodosModel);

function Demo({ ctx }) {
  const text = useSignal('');

  function add(e) {
    e.preventDefault();
    ctx.todos.add(text.value);
    text.value = '';
  }

  return <>
    <ul>
      <For each={todos.all}>
        {todo => (
          <li>
            <input type="checkbox" checked={todo.done} />
            {todo.text}
          </li>
        )}
      </For>
    </ul>
    <form onSubmit={add}>
      <input value={text} onInput={e => text.value = e.target.value} />
    </form>
  </>;
}

rpc.ready.then(() => {
  render(<Demo ctx={rpc.root} />, document.body);
});

API

Generated from TypeScript declarations.

mixed-signals/server

createMemoryTransportPair

  • Kind: Function
  • Signatures:
    • () => tuple — Creates two linked Transport instances for in-process communication. Messages sent on one end are delivered to the other via queueMicrotask.

createModel

  • Kind: Function
  • Signatures:
    • (factory: ModelFactory<TModel, TFactoryArgs>) => ModelConstructor<TModel, TFactoryArgs>

RPC

  • Kind: Class
  • Constructor:
    • new RPC(root?: any) => RPC
  • Methods:
    • addClient(transport: Transport, clientId?: string) => () => void
    • addUpstream(transport: Transport) => () => void — Register an upstream mixed-signals connection whose models are forwarded to downstream clients. All models from the upstream are automatically forwarded — no per-model declaration needed.
    • expose(root: any) => void
    • notify(method: string, params: any[], clientId?: string) => void
    • registerModel(name: string, Ctor: ModelConstructor) => void

mixed-signals/client

createReflectedModel

  • Kind: Function
  • Signatures:
    • (signalProps: string[], methods: string[]) => ModelConstructor<T, tuple>

RPCClient

  • Kind: Class
  • Constructor:
    • new RPCClient(transport: Transport, ctx?: any) => RPCClient
  • Methods:
    • call(method: string, params?: any) => Promise<any>
    • notify(method: string, params?: any[]) => void
    • onNotification(cb: (method: string, params: any[]) => void) => () => void
    • registerModel(typeName: string, ctor: any) => void
  • Properties:
    • ready: Promise<void>
    • root: any

Shared

Transport

  • Kind: Interface
  • Methods:
    • onMessage(cb: (data: { toString: unknown }) => void) => void
    • send(data: string) => void
  • Properties:
    • ready: Promise<void>

About

Use Preact Models + Signals from a server as if they lived on the client.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages