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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ ts/

`ts/packages/core` exists: a ports/adapters implementation (Transport, Storage, Identity/crypto, and Clock as first-class ports) consuming Zod schemas generated from `spec/protocol.cddl` by [cddl.js](https://github.com/ExaDev/cddl.js), with real domain logic for handshake negotiation and capability-token verification (including the delegation-chain narrowing rules `tokens.cddl` documents); its `conformance-check` round-trips every vector in `conformance/`'s golden suite through the generated schemas.

`ts/packages/web-console` exists: the browser client, a client of any node rather than just the hub. A browser-side Transport adapter implements core's port over native WebSocket (the same one-CBOR-frame-per-binary-message convention the hub speaks), and a DOM-free session module owns the client side of the handshake (with an explicit unanswered state for relay-only nodes), the gossip-derived peer directory, and the frame log the thin UI renders. Built to static assets with vite and servable from any origin; verified against a locally-running hub with the same adapter code the browser executes.

`ts/packages/cloudflare-hub` exists: a reference Cloudflare Worker deployment of a public, always-on hub node, depending on core as an ordinary workspace consumer — Worker-shaped adapters for core's ports (WebSocket-message Transport, Web Crypto Identity with signature interop proven against core's Node adapter in both directions) rather than any reinvented protocol logic, serving the relay role with gossip-based pairing. CI validates the bundle with `wrangler deploy --dry-run`; a real deploy is an authenticated one-off, not part of CI.

`rust/` exists too, mirroring the same architecture: `wire-mesh-wire` carries the CDDL model as hand-written types over a minicbor codec with CDE (canonical) encoding by construction and strict decoding (unknown keys, indefinite lengths, and non-canonical shapes all rejected), and `wire-mesh-core` carries the ports (Transport, KeyValueStorage, Identity, Clock), the same domain logic (handshake negotiation, capability-token verification with narrowing/expiry-clamping and the signed-revocation obligations, including the ancestor-chain sweep), and Tokio TCP / in-memory / Ed25519+ES256 adapters. Its `conformance-check` binary proves every golden vector round-trips byte-exactly in both directions. There is no CDDL-to-Rust generator (cddl.js emits TypeScript/Zod only), so the wire types are hand-written against `spec/*.cddl` — the frozen vectors are the cross-language pin, exactly why they exist.
Expand Down
37 changes: 37 additions & 0 deletions ts/packages/web-console/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# @exadev/wire-mesh-web-console

The browser client for wire-mesh: a client of *any* node, not just the hub. It connects over WebSocket to a node's endpoint, drives the connection through core's Transport port via a browser-side adapter, and renders what the node has to show. Built as static assets (vite), servable from any static origin — including the hub's, for deployment convenience, with no Cloudflare tie whatsoever.

## What it does

- **Connect** — enter a node's `ws://`/`wss://` URL (defaulting to `ws://localhost:8787`, the hub's `wrangler dev` port), pick the capability domains to offer, and connect. The console sends its handshake and shows the negotiated result once the node answers.
- **Peer directory** — every `gossip` frame the node sends is folded into a directory table (device, addresses, snapshot time), latest advert per device winning.
- **Frame log** — a live, ordered feed of every frame that crossed the connection in either direction, plus a *Send ping* button.

## How it maps onto core's ports

`src/adapters/websocket-transport.ts` implements core's `Transport`/`Connection` port over the browser's native WebSocket, the same message convention as the hub's Worker-side adapter (one CBOR frame per binary message, no length prefix; undecodable bytes reject the connection, a decodable-but-unknown frame drops without disconnecting). `src/mesh-session.ts` is the DOM-free session state machine — handshake exchange with an explicit *unanswered* state (a relay-only node like the hub legitimately never answers a handshake; that's displayed, not treated as an error), directory assembly, and the frame log — unit-tested against a fake Transport. `src/main.ts` is deliberately thin DOM wiring.

## Connecting to a locally-running node

```sh
# terminal 1: run the hub (a wire-mesh node) locally on :8787
cd ts/packages/cloudflare-hub && pnpm dev # wrangler dev, no credentials needed

# terminal 2: serve the console
cd ts/packages/web-console && pnpm dev # vite on :5173
```

Open the vite URL, keep the default `ws://localhost:8787` address, and connect: the status line reaches *connected*, the handshake moves to *unanswered* after the timeout (the relay-only hub never sends one back — that is the honest state, not a failure), and *Send ping* records the outgoing frame in the log (the hub drops non-relay frames by design). As fuller node implementations start answering handshakes and gossiping, the negotiated-domains line and the directory table light up with no console changes.

Verified exactly that way during development, including an end-to-end run of the *actual* adapter and session modules (not a reimplementation) against a live `wrangler dev` hub: Node 26 provides the same native WebSocket the browser does, so the same code path a browser executes connected to `ws://localhost:8787`, reached `connected`, observed the handshake go `unanswered` after the timeout (the relay-only hub never sends one -- an honest state, not a failure), and recorded an outgoing ping in the frame log. The unit suite (`pnpm test`) covers the adapter and session against fakes; the live-hub run is a development-time verification, not part of CI, because it would couple the console's CI to a running workerd.

## What is deliberately deferred

- **Room browser / join-from-browser** — the plan's phrase for the agent-comms-shaped application layer. wire-mesh's `core/data` domain carries such content as opaque entries, but no node implementation serves room semantics yet; shipping dead UI for it would be dishonest. The connection + directory + frame inspector is the honest first pass; the room UI arrives with the application that defines it.
- **Identity** — the console connects anonymously (no local keypair). When a node requires a client identity, core's Identity port gets a Web Crypto adapter here, exactly like the hub's.
- **TLS in dev** — `ws://` against localhost is fine; production deployments serve the console over HTTPS and dial `wss://`, which the adapter already handles.

## Type environment

`src/` typechecks against the DOM lib. The tests run in Node under vitest but import that src, so the test tsconfig loads node types alongside the DOM lib — and since Node 26 ships the same native WebSocket global the browser does, the adapter runs unmodified under Node, which is what makes the automated end-to-end test against a real hub possible.
29 changes: 29 additions & 0 deletions ts/packages/web-console/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { exadevConfig } from "@exadev/eslint-config";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import globals from "globals";

export default exadevConfig(
{},
{
ignores: ["dist", "coverage", "node_modules", ".turbo"],
},
{
languageOptions: {
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: import.meta.dirname,
},
// Browser globals for the app source, node globals for vite/vitest config and tests -- the tests drive the browser-shaped adapter under Node, which provides the same WebSocket global natively.
globals: { ...globals.browser, ...globals.node },
},
},
{
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{ fixStyle: "inline-type-imports" },
],
},
},
eslintPluginPrettierRecommended,
);
99 changes: 99 additions & 0 deletions ts/packages/web-console/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>wire-mesh console</title>
<style>
:root {
color-scheme: light dark;
font-family: ui-sans-serif, system-ui, sans-serif;
}
body {
margin: 0;
padding: 1rem;
}
header {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
input[type="url"] {
flex: 1;
min-width: 16rem;
}
section {
margin-top: 1rem;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
text-align: left;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
font-family: ui-monospace, monospace;
font-size: 0.85rem;
overflow-wrap: anywhere;
}
.status {
font-weight: 600;
}
.mono {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
#frame-log {
max-height: 24rem;
overflow-y: auto;
}
</style>
</head>
<body>
<h1>wire-mesh console</h1>
<form id="connect-form">
<header>
<label for="node-address">Node</label>
<input
id="node-address"
type="url"
value="ws://localhost:8787"
required
/>
<button type="submit" id="connect-button">Connect</button>
<button type="button" id="ping-button" disabled>Send ping</button>
<button type="button" id="close-button" disabled>Disconnect</button>
</header>
<p class="mono">
Domains offered:
<label><input type="checkbox" name="domain" value="core/management" checked /> core/management</label>
<label><input type="checkbox" name="domain" value="core/exec" /> core/exec</label>
<label><input type="checkbox" name="domain" value="core/data" checked /> core/data</label>
</p>
</form>
<p class="status" id="connection-status">idle</p>
<section>
<h2>Peer directory</h2>
<p id="directory-empty">No gossip received yet.</p>
<table id="directory-table" hidden>
<thead>
<tr><th>device</th><th>addresses</th><th>snapshot (unix s)</th></tr>
</thead>
<tbody id="directory-body"></tbody>
</table>
</section>
<section>
<h2>Frame log</h2>
<table>
<thead>
<tr><th>direction</th><th>frame</th></tr>
</thead>
<tbody id="frame-log"></tbody>
</table>
</section>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
36 changes: 36 additions & 0 deletions ts/packages/web-console/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@exadev/wire-mesh-web-console",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"build": "turbo run _build",
"_build": "vite build",
"dev": "vite",
"test": "turbo run _test",
"_test": "vitest run",
"typecheck": "turbo run _typecheck",
"_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
"lint": "turbo run _lint",
"_lint": "eslint . --fix --cache --max-warnings 0"
},
"dependencies": {
"@exadev/wire-mesh-core": "workspace:*",
"cbor2": "2.3.0"
},
"devDependencies": {
"@exadev/eslint-config": "2.10.6",
"@types/node": "26.4.1",
"eslint": "10.10.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "5.5.6",
"globals": "17.12.0",
"jiti": "2.7.0",
"prettier": "3.9.6",
"turbo": "2.10.12",
"typescript": "6.0.3",
"vite": "8.2.2",
"vitest": "5.0.0"
}
}
162 changes: 162 additions & 0 deletions ts/packages/web-console/src/adapters/websocket-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// A browser Transport implementation over native WebSocket messages, the same convention as the hub's Worker-side adapter: each binary WebSocket message is already self-delimiting, so one message carries exactly one CBOR-encoded frame with no length prefix. Undecodable bytes are a connection-level failure (the receive iteration rejects and the socket closes), matching core's adapters' treatment of hostile wire input; a decodable frame that fails schema validation is dropped rather than disconnecting -- an unrecognised frame from a newer peer is what version negotiation exists to tolerate.

import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
import {
frameSchema,
type Frame,
} from "@exadev/wire-mesh-core/generated/protocol";
import type {
Connection,
Listener,
Transport,
} from "@exadev/wire-mesh-core/ports/transport";

// RFC 6455 close codes, named rather than bare: 1000 normal closure, 1002 protocol error.
const CLOSE_NORMAL = 1000;
const CLOSE_PROTOCOL_ERROR = 1002;

/** A console cannot accept inbound connections, so listen() always rejects -- the port is a client here, exactly like core's other edge adapters are servers where the runtime allows it. */
const LISTEN_UNSUPPORTED = "the web console is a client only: it cannot listen";

export function messageFromFrame(frame: Frame): Uint8Array<ArrayBuffer> {
// A fresh whole-buffer view over a plain ArrayBuffer: the WebSocket send signatures require it, and it matches the fresh-buffer discipline the other adapters apply to anything crossing a runtime boundary.
return new Uint8Array(encode(frame, cdeEncodeOptions));
}

/** A frame that fails schema validation, caught separately from a decode failure so it can be dropped without disconnecting. */
class SchemaInvalidFrameError extends Error {
constructor(message: string) {
super(message);
this.name = "SchemaInvalidFrameError";
}
}

/** Decodes one message, distinguishing a decode failure (connection-level) from a schema failure (drop this frame, keep the connection). */
function decodeMessage(data: Readonly<ArrayBuffer>): Frame {
const decoded: unknown = decode(new Uint8Array(data), cdeDecodeOptions);
const result = frameSchema.safeParse(decoded);
if (!result.success) {
throw new SchemaInvalidFrameError(result.error.message);
}
return result.data;
}

export function wrapWebSocket(ws: Readonly<WebSocket>): Connection {
const pending: Frame[] = [];
const waiters: {
resolve: (result: IteratorResult<Frame>) => void;
reject: (error: unknown) => void;
}[] = [];
let ended = false;
let failure: Error | null = null;

function endAll(): void {
ended = true;
for (const waiter of waiters.splice(0)) {
waiter.resolve({ value: undefined, done: true });
}
}

function failAll(error: Error): void {
failure = error;
ended = true;
for (const waiter of waiters.splice(0)) {
waiter.reject(error);
}
}

ws.addEventListener("message", (event: MessageEvent) => {
if (!(event.data instanceof ArrayBuffer)) {
// Only binary messages carry frames; a text message is a protocol violation on this connection, same class as undecodable bytes.
failAll(new Error("expected a binary WebSocket message"));
ws.close(CLOSE_PROTOCOL_ERROR, "protocol error");
return;
}
let frame: Frame;
try {
frame = decodeMessage(event.data);
} catch (error) {
if (error instanceof SchemaInvalidFrameError) {
return;
}
failAll(
error instanceof Error
? error
: new Error(`frame body failed to decode: ${String(error)}`),
);
ws.close(CLOSE_PROTOCOL_ERROR, "protocol error");
return;
}
const waiter = waiters.shift();
if (waiter) {
waiter.resolve({ value: frame, done: false });
} else {
pending.push(frame);
}
});
ws.addEventListener("close", endAll);
ws.addEventListener("error", endAll);

const receiveStream: AsyncIterable<Frame> = {
[Symbol.asyncIterator]() {
return {
next: async (): Promise<IteratorResult<Frame>> => receiveNext(),
};
},
};

async function receiveNext(): Promise<IteratorResult<Frame>> {
const next = pending.shift();
if (next !== undefined) {
return Promise.resolve({ value: next, done: false });
}
if (failure !== null) {
return Promise.reject(failure);
}
if (ended) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve, reject) => {
waiters.push({ resolve, reject });
});
}

return {
async send(frame): Promise<void> {
if (ended) {
return Promise.reject(new Error("connection is closed"));
}
ws.send(messageFromFrame(frame));
return Promise.resolve();
},
receive: () => receiveStream,
async close(): Promise<void> {
ws.close(CLOSE_NORMAL);
return Promise.resolve();
},
};
}

export function createBrowserTransport(): Transport {
return {
async connect(address): Promise<Connection> {
const url = new URL(address);
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
return Promise.reject(new Error(`not a WebSocket address: ${address}`));
}
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
return new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => {
resolve();
});
ws.addEventListener("error", () => {
reject(new Error(`connect to ${url.toString()} failed`));
});
}).then(() => wrapWebSocket(ws));
},
async listen(): Promise<Listener> {
return Promise.reject(new Error(LISTEN_UNSUPPORTED));
},
};
}
Loading