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: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ jobs:
fi

release-core:
name: Release @exadev/wire-mesh-core
name: Release wire-mesh-core
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [required-checks]
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/cloudflare-hub/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @exadev/wire-mesh-cloudflare-hub

A reference deployment of a wire-mesh hub node on Cloudflare Workers: a public, always-on node other peers dial into, serving the relay role the repo README names for this package (transport.cddl's `relay-offer`/`relay-connect`/`relay-data`/`relay-inbound` — an opaque byte pipe when two peers can't connect directly, with device discovery over gossip). It depends on `@exadev/wire-mesh-core` as an ordinary workspace consumer and reinvents nothing the core owns: all protocol logic is core's, reached through its ports.
A reference deployment of a wire-mesh hub node on Cloudflare Workers: a public, always-on node other peers dial into, serving the relay role the repo README names for this package (transport.cddl's `relay-offer`/`relay-connect`/`relay-data`/`relay-inbound` — an opaque byte pipe when two peers can't connect directly, with device discovery over gossip). It depends on `wire-mesh-core` as an ordinary workspace consumer and reinvents nothing the core owns: all protocol logic is core's, reached through its ports.

## Why the hub lives in a Durable Object

Expand Down
2 changes: 1 addition & 1 deletion ts/packages/cloudflare-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"_lint": "eslint . --fix --cache --max-warnings 0"
},
"dependencies": {
"@exadev/wire-mesh-core": "workspace:*",
"wire-mesh-core": "workspace:*",
"cbor2": "2.3.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
// A Worker-runtime IdentityPort implementation using the standard Web Crypto API (globalThis.crypto.subtle), which exists identically in Cloudflare Workers and in Node >= 19 -- mirroring core's node-identity adapter's algorithm coverage (ES256 P-256 and Ed25519, the two algorithms identity-key.alg carries in the spec's own conformance vectors) so the two adapters are drop-in substitutes for each other behind the same port.

import type {
DeviceId,
IdentityKey,
} from "@exadev/wire-mesh-core/generated/protocol";
import type { IdentityPort } from "@exadev/wire-mesh-core/ports/identity";
import type { DeviceId, IdentityKey } from "wire-mesh-core/generated/protocol";
import type { IdentityPort } from "wire-mesh-core/ports/identity";

const ES256 = -7;
const EDDSA = -8;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
// A Worker-runtime Connection implementation over WebSocket messages, driven directly by the Durable Object entry (worker.ts's RelayHubDurableObject): the DO accepts the server side of the runtime's WebSocketPair and hands it here. Unlike the TCP adapter -- a stream, needing a length prefix to delimit frames -- each WebSocket binary message is already self-delimiting, so one message carries exactly one CBOR-encoded frame and no prefix is needed. Undecodable bytes are a connection-level failure (the receive iteration rejects and the socket closes), matching the TCP adapter's 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 } from "@exadev/wire-mesh-core/ports/transport";
import { frameSchema, type Frame } from "wire-mesh-core/generated/protocol";
import type { Connection } from "wire-mesh-core/ports/transport";

// RFC 6455 close codes, named rather than bare: 1000 normal closure, 1002 protocol error.
const CLOSE_NORMAL = 1000;
Expand Down
5 changes: 1 addition & 4 deletions ts/packages/cloudflare-hub/src/worker.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
// The Worker entry. The Durable Object class must be exported from this entrypoint file for wrangler to bind it (wrangler.toml names this export), so it is defined here directly rather than re-exported from a sibling module. A plain Worker's request context cannot host the hub's long-lived per-connection loops -- workerd's hang detection cancels any request whose promise chain parks on a pure-JS waiter (the pull-based receive() iteration), which is exactly what the relay loop does; a Durable Object is the documented home for that shape, its lifetime tied to the accepted WebSockets rather than to a single fetch. The DO keeps connections alive for its own lifetime; the hibernation API (state.acceptWebSocket + webSocketMessage handlers) is the idle-cost follow-up noted in README.md, not a correctness requirement.

import { DurableObject } from "cloudflare:workers";
import {
createRelayHub,
type RelayHub,
} from "@exadev/wire-mesh-core/domain/relay-hub";
import { createRelayHub, type RelayHub } from "wire-mesh-core/domain/relay-hub";
import { wrapWebSocket } from "./adapters/websocket-transport.js";

export function healthResponse(): Response {
Expand Down
9 changes: 3 additions & 6 deletions ts/packages/cloudflare-hub/test/hub.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { describe, expect, it } from "vitest";
import { decode } from "cbor2";
import type {
DeviceId,
Frame,
} from "@exadev/wire-mesh-core/generated/protocol";
import type { Connection } from "@exadev/wire-mesh-core/ports/transport";
import { createRelayHub } from "@exadev/wire-mesh-core/domain/relay-hub";
import type { DeviceId, Frame } from "wire-mesh-core/generated/protocol";
import type { Connection } from "wire-mesh-core/ports/transport";
import { createRelayHub } from "wire-mesh-core/domain/relay-hub";
import {
messageFromFrame,
wrapWebSocket,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import {
createNodeIdentity,
verifyWithPublicKey as verifyWithNodeIdentity,
} from "@exadev/wire-mesh-core/adapters/node-identity";
} from "wire-mesh-core/adapters/node-identity";
import { bytesFromHex } from "./hex.js";

const ES256 = -7;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { decode, encode } from "cbor2";
import type { Frame } from "@exadev/wire-mesh-core/generated/protocol";
import type { Frame } from "wire-mesh-core/generated/protocol";
import {
messageFromFrame,
wrapWebSocket,
Expand Down
4 changes: 2 additions & 2 deletions ts/packages/core/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# @exadev/wire-mesh-core
# wire-mesh-core

The TypeScript implementation of wire-mesh's protocol, built ports/adapters: domain logic (`src/domain/`) depends only on port contracts (`src/ports/`) and the [cddl.js](https://github.com/ExaDev/cddl.js)-generated Zod schemas (`src/generated/protocol.ts`, regenerated from `../../../spec/protocol.cddl` via `generate.ts` -- never edited by hand), never on a specific adapter's own imports.

Expand Down Expand Up @@ -29,4 +29,4 @@ CI regenerates and diffs against the committed file, the same way `conformance/`

## Publishing

Published to npm as [`@exadev/wire-mesh-core`](https://www.npmjs.com/package/@exadev/wire-mesh-core). A push to `main` runs `semantic-release` (`ts/packages/core/release.config.ts`, tagged `core-v*` to keep this package's releases distinct from any other publishable package in the workspace), which versions from conventional-commit messages, builds `dist/` fresh, and publishes it -- `dist/` itself is never committed to the repo.
Published to npm as [`wire-mesh-core`](https://www.npmjs.com/package/wire-mesh-core). A push to `main` runs `semantic-release` (`ts/packages/core/release.config.ts`, tagged `core-v*` to keep this package's releases distinct from any other publishable package in the workspace), which versions from conventional-commit messages, builds `dist/` fresh, and publishes it -- `dist/` itself is never committed to the repo.
2 changes: 1 addition & 1 deletion ts/packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@exadev/wire-mesh-core",
"name": "wire-mesh-core",
"version": "0.0.0",
"type": "module",
"packageManager": "pnpm@10.33.0",
Expand Down
4 changes: 2 additions & 2 deletions ts/packages/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A self-hostable, no-cloud LAN counterpart to `@exadev/wire-mesh-cloudflare-hub`:

## How it maps onto core's ports

The relay/pairing/gossip-registry domain logic itself is not duplicated here — it is `@exadev/wire-mesh-core`'s `./domain/relay-hub` export, the same module `cloudflare-hub` consumes, proven transport-agnostic by running against core's TCP adapter, `cloudflare-hub`'s WebSocket adapter, and this package's own adapter alike. This package supplies exactly one new thing: a **Connection**/**Transport** port implementation over the `ws` npm package (`src/adapters/node-websocket-transport.ts`) — the same one-CBOR-frame-per-binary-message convention as the hub's and web-console's own WebSocket adapters, but driven through `ws`'s idiomatic Node `EventEmitter` API (`.on`, not `.addEventListener`) rather than the platform `WebSocket` those two wrap. Unlike the browser adapter, whose `listen()` always rejects (a browser tab can never accept inbound connections), and unlike `cloudflare-hub`'s adapter, which has no `listen()` at all (ingress arrives through the Durable Object's own upgrade handling), this adapter's `listen()` is a real implementation: this package's entire reason to exist is being reachable on a LAN.
The relay/pairing/gossip-registry domain logic itself is not duplicated here — it is `wire-mesh-core`'s `./domain/relay-hub` export, the same module `cloudflare-hub` consumes, proven transport-agnostic by running against core's TCP adapter, `cloudflare-hub`'s WebSocket adapter, and this package's own adapter alike. This package supplies exactly one new thing: a **Connection**/**Transport** port implementation over the `ws` npm package (`src/adapters/node-websocket-transport.ts`) — the same one-CBOR-frame-per-binary-message convention as the hub's and web-console's own WebSocket adapters, but driven through `ws`'s idiomatic Node `EventEmitter` API (`.on`, not `.addEventListener`) rather than the platform `WebSocket` those two wrap. Unlike the browser adapter, whose `listen()` always rejects (a browser tab can never accept inbound connections), and unlike `cloudflare-hub`'s adapter, which has no `listen()` at all (ingress arrives through the Durable Object's own upgrade handling), this adapter's `listen()` is a real implementation: this package's entire reason to exist is being reachable on a LAN.

`src/server.ts` is the CLI entrypoint: it wires `createRelayHub()` over `createNodeWebSocketTransport()`, answering any plain (non-Upgrade) HTTP request on the same listener with a small JSON health response, the same shape `cloudflare-hub`'s own `healthResponse()` returns.

Expand Down Expand Up @@ -49,4 +49,4 @@ Deferred deliberately, matching `cloudflare-hub`'s own list:

## Type environment

`src/` is plain Node code, typechecked against `@types/node` and `@types/ws` — no DOM lib, no Worker types, matching `@exadev/wire-mesh-core`'s own type environment exactly (the closest precedent this package's scaffolding mirrors).
`src/` is plain Node code, typechecked against `@types/node` and `@types/ws` — no DOM lib, no Worker types, matching `wire-mesh-core`'s own type environment exactly (the closest precedent this package's scaffolding mirrors).
2 changes: 1 addition & 1 deletion ts/packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"_lint": "eslint . --fix --cache --max-warnings 0"
},
"dependencies": {
"@exadev/wire-mesh-core": "workspace:*",
"wire-mesh-core": "workspace:*",
"cbor2": "2.3.0",
"ws": "8.21.3"
},
Expand Down
7 changes: 2 additions & 5 deletions ts/packages/node/src/adapters/node-websocket-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,12 @@ import {
type ServerResponse,
} from "node:http";
import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
import {
frameSchema,
type Frame,
} from "@exadev/wire-mesh-core/generated/protocol";
import { frameSchema, type Frame } from "wire-mesh-core/generated/protocol";
import type {
Connection,
Listener,
Transport,
} from "@exadev/wire-mesh-core/ports/transport";
} from "wire-mesh-core/ports/transport";
import { WebSocket, WebSocketServer, type RawData } from "ws";

// RFC 6455 close codes, named rather than bare: 1000 normal closure, 1002 protocol error.
Expand Down
4 changes: 2 additions & 2 deletions ts/packages/node/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
// The wire-mesh-node CLI entrypoint: a self-hostable, no-cloud LAN counterpart to cloudflare-hub, wiring the same shared relay-hub domain logic from @exadev/wire-mesh-core over a real Node WebSocket + http server instead of a Cloudflare Durable Object. Default bind address is 0.0.0.0, not loopback -- the whole point of this package is LAN reachability, unlike a dev-server tool's usual loopback-only default.
// The wire-mesh-node CLI entrypoint: a self-hostable, no-cloud LAN counterpart to cloudflare-hub, wiring the same shared relay-hub domain logic from wire-mesh-core over a real Node WebSocket + http server instead of a Cloudflare Durable Object. Default bind address is 0.0.0.0, not loopback -- the whole point of this package is LAN reachability, unlike a dev-server tool's usual loopback-only default.

import { createRelayHub } from "@exadev/wire-mesh-core/domain/relay-hub";
import { createRelayHub } from "wire-mesh-core/domain/relay-hub";
import { createNodeWebSocketTransport } from "./adapters/node-websocket-transport.js";

export function healthResponse(): { ok: true; node: string; roles: string[] } {
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/node/test/node-websocket-transport.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { encode } from "cbor2";
import type { Frame } from "@exadev/wire-mesh-core/generated/protocol";
import type { Frame } from "wire-mesh-core/generated/protocol";
import type { WebSocket } from "ws";
import {
createNodeWebSocketTransport,
Expand Down
7 changes: 2 additions & 5 deletions ts/packages/node/test/relay-end-to-end.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
// Real, in-suite equivalent of cloudflare-hub/scripts/live-check.mjs: two genuine wire-mesh-node clients gossiping, relay-connecting, and exchanging relay-data through a real createRelayHub() wired over a real createNodeWebSocketTransport() listener -- the same health-check wiring server.ts itself uses, so this test also proves the plain-HTTP health path works on the same listener a WebSocket client connects to.

import { describe, expect, it } from "vitest";
import { createRelayHub } from "@exadev/wire-mesh-core/domain/relay-hub";
import type {
DeviceId,
Frame,
} from "@exadev/wire-mesh-core/generated/protocol";
import { createRelayHub } from "wire-mesh-core/domain/relay-hub";
import type { DeviceId, Frame } from "wire-mesh-core/generated/protocol";
import { createNodeWebSocketTransport } from "../src/adapters/node-websocket-transport.js";
import { healthResponse } from "../src/server.js";
import { bytesFromHex, deviceIdFromFillHex } from "./hex.js";
Expand Down
6 changes: 3 additions & 3 deletions ts/packages/web-console/live-check/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import type {
CapabilityToken,
Frame,
TokenClaims,
} from "@exadev/wire-mesh-core/generated/protocol";
import type { Connection } from "@exadev/wire-mesh-core/ports/transport";
} from "wire-mesh-core/generated/protocol";
import type { Connection } from "wire-mesh-core/ports/transport";
import { createWebCryptoIdentity } from "../src/adapters/web-crypto-identity.js";
import { createBrowserTransport } from "../src/adapters/websocket-transport.js";
import {
createMeshSession,
type SessionEvent,
} from "@exadev/wire-mesh-core/domain/mesh-session";
} from "wire-mesh-core/domain/mesh-session";
import {
WEBRTC_SIGNAL_SCOPE,
WEBRTC_SIGNAL_VERB,
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/web-console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"_lint": "eslint . --fix --cache --max-warnings 0"
},
"dependencies": {
"@exadev/wire-mesh-core": "workspace:*",
"wire-mesh-core": "workspace:*",
"cbor2": "2.3.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/web-console/src/adapters/indexeddb-storage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// A browser KeyValueStorage implementation over IndexedDB -- the one storage the console has that survives a reload, unlike an in-process Map. One object store holds opaque Uint8Array values keyed by string, matching the port's contract exactly; nothing here interprets what's stored.

import type { KeyValueStorage } from "@exadev/wire-mesh-core/ports/storage";
import type { KeyValueStorage } from "wire-mesh-core/ports/storage";

export interface IndexedDbStorageOptions {
dbName?: string;
Expand Down
9 changes: 3 additions & 6 deletions ts/packages/web-console/src/adapters/web-crypto-identity.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
// A Worker-runtime IdentityPort implementation using the standard Web Crypto API (globalThis.crypto.subtle), which exists identically in Cloudflare Workers and in Node >= 19 -- mirroring core's node-identity adapter's algorithm coverage (ES256 P-256 and Ed25519, the two algorithms identity-key.alg carries in the spec's own conformance vectors) so the two adapters are drop-in substitutes for each other behind the same port.

import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
import type {
DeviceId,
IdentityKey,
} from "@exadev/wire-mesh-core/generated/protocol";
import type { IdentityPort } from "@exadev/wire-mesh-core/ports/identity";
import type { KeyValueStorage } from "@exadev/wire-mesh-core/ports/storage";
import type { DeviceId, IdentityKey } from "wire-mesh-core/generated/protocol";
import type { IdentityPort } from "wire-mesh-core/ports/identity";
import type { KeyValueStorage } from "wire-mesh-core/ports/storage";

const ES256 = -7;
const EDDSA = -8;
Expand Down
6 changes: 3 additions & 3 deletions ts/packages/web-console/src/adapters/webrtc-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
//
// Exactly like createBrowserTransport's connect() sets ws.binaryType before calling wrapWebSocket, the caller here (webrtc-negotiation.ts) must set channel.binaryType = "arraybuffer" explicitly -- not relied on as an unstated default -- on the still-mutable channel before handing it to wrapRtcDataChannel. This adapter takes a Readonly channel and never mutates it itself.

import type { Frame } from "@exadev/wire-mesh-core/generated/protocol";
import type { Connection } from "@exadev/wire-mesh-core/ports/transport";
import type { Frame } from "wire-mesh-core/generated/protocol";
import type { Connection } from "wire-mesh-core/ports/transport";
import {
SchemaInvalidFrameError,
decodeMessage,
messageFromFrame,
} from "@exadev/wire-mesh-core/adapters/frame-codec";
} from "wire-mesh-core/adapters/frame-codec";

export function wrapRtcDataChannel(
channel: Readonly<RTCDataChannel>,
Expand Down
6 changes: 3 additions & 3 deletions ts/packages/web-console/src/adapters/websocket-transport.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// 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 type { Frame } from "@exadev/wire-mesh-core/generated/protocol";
import type { Frame } from "wire-mesh-core/generated/protocol";
import type {
Connection,
Listener,
Transport,
} from "@exadev/wire-mesh-core/ports/transport";
} from "wire-mesh-core/ports/transport";
import {
SchemaInvalidFrameError,
decodeMessage,
messageFromFrame,
} from "@exadev/wire-mesh-core/adapters/frame-codec";
} from "wire-mesh-core/adapters/frame-codec";

// RFC 6455 close codes, named rather than bare: 1000 normal closure, 1002 protocol error.
const CLOSE_NORMAL = 1000;
Expand Down
6 changes: 3 additions & 3 deletions ts/packages/web-console/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// The console's DOM wiring: one MeshSession per open connection, rendering each session's SessionEvent into its own status line, peer directory table, and frame log. Kept thin on purpose -- everything with behaviour lives in @exadev/wire-mesh-core's own mesh-session domain module so it can be tested without a browser.
// The console's DOM wiring: one MeshSession per open connection, rendering each session's SessionEvent into its own status line, peer directory table, and frame log. Kept thin on purpose -- everything with behaviour lives in wire-mesh-core's own mesh-session domain module so it can be tested without a browser.

import { createIndexedDbStorage } from "./adapters/indexeddb-storage.js";
import { createPersistedWebCryptoIdentity } from "./adapters/web-crypto-identity.js";
import { createBrowserTransport } from "./adapters/websocket-transport.js";
import { createMeshSession } from "@exadev/wire-mesh-core/domain/mesh-session";
import { createMeshSession } from "wire-mesh-core/domain/mesh-session";
import type {
ReconnectPolicy,
SessionEvent,
} from "@exadev/wire-mesh-core/domain/mesh-session";
} from "wire-mesh-core/domain/mesh-session";

// Passing the constructor rather than asserting: T appears in both the parameter and return, and the instanceof check makes the lookup self-verifying at runtime.
function requireElement<E extends HTMLElement>(
Expand Down
Loading