wire-mesh 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/adapters/node-websocket-transport.cjs +183 -0
- package/dist/adapters/node-websocket-transport.d.cts +18 -0
- package/dist/adapters/node-websocket-transport.d.mts +18 -0
- package/dist/adapters/node-websocket-transport.mjs +180 -0
- package/dist/server.cjs +126 -0
- package/dist/server.d.cts +17 -0
- package/dist/server.d.mts +17 -0
- package/dist/server.mjs +122 -0
- package/dist/web-console/assets/index-BMlnc-0m.css +1 -0
- package/dist/web-console/assets/index-CFw8TlSC.js +72 -0
- package/dist/web-console/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
- package/dist/web-console/favicon.svg +13 -0
- package/dist/web-console/icons/icon-192x192.svg +13 -0
- package/dist/web-console/icons/icon-512x512.svg +13 -0
- package/dist/web-console/index.html +19 -0
- package/dist/web-console/manifest.webmanifest +1 -0
- package/dist/web-console/sw.js +1 -0
- package/dist/web-console/workbox-2fbc6a65.js +1 -0
- package/package.json +67 -2
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# wire-mesh
|
|
2
|
+
|
|
3
|
+
A self-hostable, no-cloud LAN counterpart to `@exadev/wire-mesh-cloudflare-hub`: the same relay role (device discovery over gossip, pairing `relay-connect` initiators with their target, forwarding `relay-data` both ways), served by a plain Node process instead of a Cloudflare Durable Object. Run it on any machine already reachable on the network — a home server, a laptop on the same Wi-Fi, a container on a LAN — with no Cloudflare account, no deployment step, and no cloud dependency at all.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
`cloudflare-hub` is the always-on, public reference deployment. It needs an account and a deployment pipeline, and it puts every relayed byte through Cloudflare's network. `wire-mesh` is for the opposite case: two devices on the same LAN (or reachable over a VPN/tailnet) that want a relay neither of them has to pay for or deploy anywhere — just `npx wire-mesh` on whichever machine is already running.
|
|
8
|
+
|
|
9
|
+
## How it maps onto core's ports
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
`src/server.ts` is the CLI entrypoint: it wires `createRelayHub()` over `createNodeWebSocketTransport()`. The same listener answers a browser too: `GET /health` returns a small JSON health response (the same shape `cloudflare-hub`'s own `healthResponse()` returns), and every other request is served from `@exadev/wire-mesh-web-console`'s own built static output — copied into this package's `dist/web-console` at build time (`scripts/copy-web-console-dist.mjs`, run as the second half of the `_build` script), so a self-hosted node has somewhere to point a browser at, not just other wire-mesh peers (wire-mesh#184). An extension-less path that doesn't match a real file falls back to `index.html`, so the console's own client-side routes still resolve.
|
|
14
|
+
|
|
15
|
+
## Running it
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx wire-mesh # binds 0.0.0.0:8787, plain ws:// + http://
|
|
19
|
+
npx wire-mesh --bind 0.0.0.0:9000 # a different port
|
|
20
|
+
npx wire-mesh --bind 127.0.0.1:8787 # loopback only, if that's genuinely what you want
|
|
21
|
+
npx wire-mesh --tls-cert cert.pem --tls-key key.pem # wss:// + https://, cert/key must be given together
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
or, installed as a dependency:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pnpm add wire-mesh
|
|
28
|
+
wire-mesh --bind 0.0.0.0:8787
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Default bind address is `0.0.0.0:8787`, not loopback.** Unlike a typical dev-server tool, whose loopback-only default assumes only the machine itself needs to reach it, this package's whole purpose is LAN reachability — a phone on the same network, a laptop in the next room. `--bind` overrides the address if you want to restrict it (loopback-only, a specific interface, a different port).
|
|
32
|
+
|
|
33
|
+
Point a browser at the bound address to reach the console, or check its health directly:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
curl http://<host>:8787/health
|
|
37
|
+
# {"ok":true,"node":"wire-mesh","roles":["relay"]}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**TLS (`wss://`/`https://`)** is opt-in via `--tls-cert`/`--tls-key`, both required together — this package generates no certificate of its own, so bring your own (a real one from a CA, or a self-signed one for a LAN). This matters specifically for wire-mesh#182/#183: an `https://`-served PWA (`mesh.exadev.io`) can only reach a plain `ws://` node if it's literally on `localhost` of the same machine, since browsers block mixed-content WebSocket connections from a secure page to an insecure one — a self-hosted node on someone's LAN, addressed by its own IP, needs to answer `wss://` for that PWA to reach it at all.
|
|
41
|
+
|
|
42
|
+
**Security note:** this package adds no access control beyond the protocol's own capability tokens (`core/exec`/`core/management` verbs gated by a signed capability, same as any other wire-mesh node) — anyone who can reach the bound address can gossip, relay-connect, and relay-data through it. Bind to a trusted network (a home LAN, a VPN/tailnet) rather than a public interface unless the capability-token story for whatever you're relaying is one you're comfortable exposing to the open internet.
|
|
43
|
+
|
|
44
|
+
## What is real versus deferred
|
|
45
|
+
|
|
46
|
+
Real and tested: the Node WebSocket transport adapter (hostile-input behaviour included — undecodable bytes and non-binary messages close the connection; a decodable-but-schema-invalid frame drops without disconnecting), a real loopback round trip (`listen("127.0.0.1:0", ...)` + `connect(...)` against the actual bound address, proving the OS-assigned-port path works), a full relay path verified end to end against real sockets — two genuine WebSocket clients exchanging gossip, relay-connect, relay-inbound, and bidirectional relay-data through a real listener, the in-suite analogue of `cloudflare-hub/scripts/live-check.mjs` for a runtime (plain Node) with no dev-server lifecycle constraints to work around — a real TLS handshake terminating a genuine `wss://` WebSocket connection and an `https://` request against a self-signed fixture certificate, and the console's own static-file routing (root, nested assets, the service worker's required `Service-Worker-Allowed` header, the client-side-route fallback to `index.html`, and a 404 for a genuinely missing file) against a real HTTP server.
|
|
47
|
+
|
|
48
|
+
Deferred deliberately, matching `cloudflare-hub`'s own list:
|
|
49
|
+
|
|
50
|
+
- **The announcer role** (`discovery.cddl`'s `mailboxes`) needs a Storage port adapter; none is wired up here yet.
|
|
51
|
+
|
|
52
|
+
## Type environment
|
|
53
|
+
|
|
54
|
+
`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).
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_http = require("node:http");
|
|
3
|
+
let node_https = require("node:https");
|
|
4
|
+
let cbor2 = require("cbor2");
|
|
5
|
+
let wire_mesh_core_generated_protocol = require("wire-mesh-core/generated/protocol");
|
|
6
|
+
let ws = require("ws");
|
|
7
|
+
//#region src/adapters/node-websocket-transport.ts
|
|
8
|
+
const CLOSE_NORMAL = 1e3;
|
|
9
|
+
const CLOSE_PROTOCOL_ERROR = 1002;
|
|
10
|
+
const HTTP_NOT_FOUND = 404;
|
|
11
|
+
function hostPortToWsUrl(address) {
|
|
12
|
+
const { host, port } = parseAddress(address);
|
|
13
|
+
return `ws://${host}:${String(port)}`;
|
|
14
|
+
}
|
|
15
|
+
function parseAddress(address) {
|
|
16
|
+
const lastColon = address.lastIndexOf(":");
|
|
17
|
+
if (lastColon === -1) throw new Error(`expected "host:port", got "${address}"`);
|
|
18
|
+
return {
|
|
19
|
+
host: address.slice(0, lastColon),
|
|
20
|
+
port: Number(address.slice(lastColon + 1))
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function messageFromFrame(frame) {
|
|
24
|
+
return new Uint8Array((0, cbor2.encode)(frame, cbor2.cdeEncodeOptions));
|
|
25
|
+
}
|
|
26
|
+
/** A frame that fails schema validation, caught separately from a decode failure so it can be dropped without disconnecting the peer. */
|
|
27
|
+
var SchemaInvalidFrameError = class extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "SchemaInvalidFrameError";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** Decodes one message, distinguishing a decode failure (connection-level) from a schema failure (drop this frame, keep the connection) -- mirroring the other adapters' split between the two. */
|
|
34
|
+
function decodeMessage(data) {
|
|
35
|
+
let decoded;
|
|
36
|
+
try {
|
|
37
|
+
decoded = (0, cbor2.decode)(data, cbor2.cdeDecodeOptions);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw error instanceof Error ? error : /* @__PURE__ */ new Error(`frame body failed to decode: ${String(error)}`);
|
|
40
|
+
}
|
|
41
|
+
const result = wire_mesh_core_generated_protocol.frameSchema.safeParse(decoded);
|
|
42
|
+
if (!result.success) throw new SchemaInvalidFrameError(result.error.message);
|
|
43
|
+
return result.data;
|
|
44
|
+
}
|
|
45
|
+
/** Narrows `ws`'s RawData (Buffer | ArrayBuffer | Buffer[]) to a single contiguous, genuinely plain Uint8Array. `ws` only ever delivers an array of buffers when a message arrives fragmented across a stream that itself isn't being reassembled -- doesn't happen for the default (non-streamed) receive path this adapter uses. A Node Buffer IS a Uint8Array subclass, but handing one to cbor2's decode() as-is leaks that subclass into every nested byte-string value decode() returns (it slices the same buffer type it was given); cbor2's encode() then does not recognise a Buffer as a plain byte string and serialises it as a generic object instead, corrupting any value (like a device-id) that round-trips decode -\> store -\> re-encode, as this hub's relay-connect -\> relay-inbound path does. Always copying into a fresh Uint8Array here, the same normalisation the browser/Worker adapters get for free from `new Uint8Array(event.data)` on their own already-ArrayBuffer input, is the fix. */
|
|
46
|
+
function bytesFromRawData(data) {
|
|
47
|
+
if (Array.isArray(data)) throw new Error("expected a single WebSocket message, got fragments");
|
|
48
|
+
return new Uint8Array(data);
|
|
49
|
+
}
|
|
50
|
+
/** Wraps an already-open `ws` WebSocket (client or server side) as a Connection, driven through `ws`'s EventEmitter API (`.on`, not `.addEventListener` -- this repo's other Node-native adapter, tcp-transport.ts, uses the same idiomatic-Node style; `.addEventListener` is reserved for the two adapters wrapping an actual platform WebSocket). */
|
|
51
|
+
function wrapNodeWebSocket(ws$1) {
|
|
52
|
+
const pending = [];
|
|
53
|
+
const waiters = [];
|
|
54
|
+
let ended = false;
|
|
55
|
+
let failure = null;
|
|
56
|
+
function endAll() {
|
|
57
|
+
ended = true;
|
|
58
|
+
for (const waiter of waiters.splice(0)) waiter.resolve({
|
|
59
|
+
value: void 0,
|
|
60
|
+
done: true
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function failAll(error) {
|
|
64
|
+
failure = error;
|
|
65
|
+
ended = true;
|
|
66
|
+
for (const waiter of waiters.splice(0)) waiter.reject(error);
|
|
67
|
+
}
|
|
68
|
+
ws$1.on("message", (data, isBinary) => {
|
|
69
|
+
if (!isBinary) {
|
|
70
|
+
failAll(/* @__PURE__ */ new Error("expected a binary WebSocket message"));
|
|
71
|
+
ws$1.close(CLOSE_PROTOCOL_ERROR, "protocol error");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let frame;
|
|
75
|
+
try {
|
|
76
|
+
frame = decodeMessage(bytesFromRawData(data));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof SchemaInvalidFrameError) return;
|
|
79
|
+
failAll(error instanceof Error ? error : /* @__PURE__ */ new Error(`frame body failed to decode: ${String(error)}`));
|
|
80
|
+
ws$1.close(CLOSE_PROTOCOL_ERROR, "protocol error");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const waiter = waiters.shift();
|
|
84
|
+
if (waiter) waiter.resolve({
|
|
85
|
+
value: frame,
|
|
86
|
+
done: false
|
|
87
|
+
});
|
|
88
|
+
else pending.push(frame);
|
|
89
|
+
});
|
|
90
|
+
ws$1.on("close", endAll);
|
|
91
|
+
ws$1.on("error", endAll);
|
|
92
|
+
const receiveStream = { [Symbol.asyncIterator]() {
|
|
93
|
+
return { async next() {
|
|
94
|
+
const next = pending.shift();
|
|
95
|
+
if (next !== void 0) return {
|
|
96
|
+
value: next,
|
|
97
|
+
done: false
|
|
98
|
+
};
|
|
99
|
+
if (failure !== null) throw failure;
|
|
100
|
+
if (ended) return {
|
|
101
|
+
value: void 0,
|
|
102
|
+
done: true
|
|
103
|
+
};
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
waiters.push({
|
|
106
|
+
resolve,
|
|
107
|
+
reject
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
} };
|
|
111
|
+
} };
|
|
112
|
+
return {
|
|
113
|
+
async send(frame) {
|
|
114
|
+
if (ended) return Promise.reject(/* @__PURE__ */ new Error("connection is closed"));
|
|
115
|
+
ws$1.send(messageFromFrame(frame));
|
|
116
|
+
return Promise.resolve();
|
|
117
|
+
},
|
|
118
|
+
receive: () => receiveStream,
|
|
119
|
+
async close() {
|
|
120
|
+
ws$1.close(CLOSE_NORMAL);
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** A Node `ws`-based Transport: one CBOR frame per binary WebSocket message, served over a plain `http.Server` this adapter owns, or an `https.Server` when `options.tls` is given. `listen()` is a real implementation (unlike the browser adapter, a client can never accept connections) and supports port 0 for an OS-assigned port, reading the real bound address back the same way core's tcp-transport.ts does. */
|
|
126
|
+
function createNodeWebSocketTransport(options = {}) {
|
|
127
|
+
return {
|
|
128
|
+
async connect(address) {
|
|
129
|
+
const url = /^wss?:\/\/./.test(address);
|
|
130
|
+
if (!url && !/^[^/]+:\d+$/.test(address)) throw new Error(`expected "host:port" or a ws:// / wss:// URL, got "${address}"`);
|
|
131
|
+
const target = url ? address : hostPortToWsUrl(address);
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const ws$2 = new ws.WebSocket(target);
|
|
134
|
+
ws$2.once("open", () => {
|
|
135
|
+
resolve(wrapNodeWebSocket(ws$2));
|
|
136
|
+
});
|
|
137
|
+
ws$2.once("error", reject);
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
async listen(address, onConnection) {
|
|
141
|
+
const { host, port } = parseAddress(address);
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const requestHandler = (request, response) => {
|
|
144
|
+
if (options.onHttpRequest) {
|
|
145
|
+
options.onHttpRequest(request, response);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
response.writeHead(HTTP_NOT_FOUND).end();
|
|
149
|
+
};
|
|
150
|
+
const httpServer = options.tls ? (0, node_https.createServer)({
|
|
151
|
+
cert: options.tls.certificatePem,
|
|
152
|
+
key: options.tls.privateKeyPem
|
|
153
|
+
}, requestHandler) : (0, node_http.createServer)(requestHandler);
|
|
154
|
+
const wss = new ws.WebSocketServer({ server: httpServer });
|
|
155
|
+
wss.on("connection", (ws$3) => {
|
|
156
|
+
onConnection(wrapNodeWebSocket(ws$3));
|
|
157
|
+
});
|
|
158
|
+
httpServer.once("error", reject);
|
|
159
|
+
httpServer.listen(port, host, () => {
|
|
160
|
+
const bound = httpServer.address();
|
|
161
|
+
if (bound === null || typeof bound === "string") {
|
|
162
|
+
reject(/* @__PURE__ */ new Error("listener did not report a bound address"));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
resolve({
|
|
166
|
+
close: async () => new Promise((resolveClose) => {
|
|
167
|
+
wss.close(() => {
|
|
168
|
+
httpServer.close(() => {
|
|
169
|
+
resolveClose();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}),
|
|
173
|
+
address: `${bound.address}:${String(bound.port)}`
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
exports.createNodeWebSocketTransport = createNodeWebSocketTransport;
|
|
182
|
+
exports.messageFromFrame = messageFromFrame;
|
|
183
|
+
exports.wrapNodeWebSocket = wrapNodeWebSocket;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { Frame } from "wire-mesh-core/generated/protocol";
|
|
3
|
+
import { TlsIdentity } from "wire-mesh-core/adapters/tls-transport";
|
|
4
|
+
import { Connection, Transport } from "wire-mesh-core/ports/transport";
|
|
5
|
+
import { WebSocket } from "ws";
|
|
6
|
+
//#region src/adapters/node-websocket-transport.d.ts
|
|
7
|
+
export declare function messageFromFrame(frame: Frame): Uint8Array<ArrayBuffer>;
|
|
8
|
+
/** Wraps an already-open `ws` WebSocket (client or server side) as a Connection, driven through `ws`'s EventEmitter API (`.on`, not `.addEventListener` -- this repo's other Node-native adapter, tcp-transport.ts, uses the same idiomatic-Node style; `.addEventListener` is reserved for the two adapters wrapping an actual platform WebSocket). */
|
|
9
|
+
export declare function wrapNodeWebSocket(ws: WebSocket): Connection;
|
|
10
|
+
export interface NodeWebSocketTransportOptions {
|
|
11
|
+
/** Answers a non-Upgrade HTTP request landing on the same listener (e.g. a health check). Requests are 404'd if omitted. */
|
|
12
|
+
onHttpRequest?: (request: IncomingMessage, response: ServerResponse) => void;
|
|
13
|
+
/** Serves the listener over TLS (wss://) instead of plain ws:// when given — reusing core's own TlsIdentity shape (already a dependency of this package) rather than a parallel cert/key type for the same concept. Unlike createTlsTransport's mutual-auth model, this adapter never requests a client certificate: a browser's WebSocket client cannot present one, and the whole point of wss:// here is letting an ordinary browser (mesh.exadev.io, wire-mesh#182/#183) reach this node without mixed-content blocking. */
|
|
14
|
+
tls?: TlsIdentity;
|
|
15
|
+
}
|
|
16
|
+
/** A Node `ws`-based Transport: one CBOR frame per binary WebSocket message, served over a plain `http.Server` this adapter owns, or an `https.Server` when `options.tls` is given. `listen()` is a real implementation (unlike the browser adapter, a client can never accept connections) and supports port 0 for an OS-assigned port, reading the real bound address back the same way core's tcp-transport.ts does. */
|
|
17
|
+
export declare function createNodeWebSocketTransport(options?: Readonly<NodeWebSocketTransportOptions>): Transport;
|
|
18
|
+
//#endregion
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { Frame } from "wire-mesh-core/generated/protocol";
|
|
3
|
+
import { WebSocket } from "ws";
|
|
4
|
+
import { TlsIdentity } from "wire-mesh-core/adapters/tls-transport";
|
|
5
|
+
import { Connection, Transport } from "wire-mesh-core/ports/transport";
|
|
6
|
+
//#region src/adapters/node-websocket-transport.d.ts
|
|
7
|
+
export declare function messageFromFrame(frame: Frame): Uint8Array<ArrayBuffer>;
|
|
8
|
+
/** Wraps an already-open `ws` WebSocket (client or server side) as a Connection, driven through `ws`'s EventEmitter API (`.on`, not `.addEventListener` -- this repo's other Node-native adapter, tcp-transport.ts, uses the same idiomatic-Node style; `.addEventListener` is reserved for the two adapters wrapping an actual platform WebSocket). */
|
|
9
|
+
export declare function wrapNodeWebSocket(ws: WebSocket): Connection;
|
|
10
|
+
export interface NodeWebSocketTransportOptions {
|
|
11
|
+
/** Answers a non-Upgrade HTTP request landing on the same listener (e.g. a health check). Requests are 404'd if omitted. */
|
|
12
|
+
onHttpRequest?: (request: IncomingMessage, response: ServerResponse) => void;
|
|
13
|
+
/** Serves the listener over TLS (wss://) instead of plain ws:// when given — reusing core's own TlsIdentity shape (already a dependency of this package) rather than a parallel cert/key type for the same concept. Unlike createTlsTransport's mutual-auth model, this adapter never requests a client certificate: a browser's WebSocket client cannot present one, and the whole point of wss:// here is letting an ordinary browser (mesh.exadev.io, wire-mesh#182/#183) reach this node without mixed-content blocking. */
|
|
14
|
+
tls?: TlsIdentity;
|
|
15
|
+
}
|
|
16
|
+
/** A Node `ws`-based Transport: one CBOR frame per binary WebSocket message, served over a plain `http.Server` this adapter owns, or an `https.Server` when `options.tls` is given. `listen()` is a real implementation (unlike the browser adapter, a client can never accept connections) and supports port 0 for an OS-assigned port, reading the real bound address back the same way core's tcp-transport.ts does. */
|
|
17
|
+
export declare function createNodeWebSocketTransport(options?: Readonly<NodeWebSocketTransportOptions>): Transport;
|
|
18
|
+
//#endregion
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createServer as createServer$1 } from "node:https";
|
|
3
|
+
import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
|
|
4
|
+
import { frameSchema } from "wire-mesh-core/generated/protocol";
|
|
5
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
6
|
+
//#region src/adapters/node-websocket-transport.ts
|
|
7
|
+
const CLOSE_NORMAL = 1e3;
|
|
8
|
+
const CLOSE_PROTOCOL_ERROR = 1002;
|
|
9
|
+
const HTTP_NOT_FOUND = 404;
|
|
10
|
+
function hostPortToWsUrl(address) {
|
|
11
|
+
const { host, port } = parseAddress(address);
|
|
12
|
+
return `ws://${host}:${String(port)}`;
|
|
13
|
+
}
|
|
14
|
+
function parseAddress(address) {
|
|
15
|
+
const lastColon = address.lastIndexOf(":");
|
|
16
|
+
if (lastColon === -1) throw new Error(`expected "host:port", got "${address}"`);
|
|
17
|
+
return {
|
|
18
|
+
host: address.slice(0, lastColon),
|
|
19
|
+
port: Number(address.slice(lastColon + 1))
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function messageFromFrame(frame) {
|
|
23
|
+
return new Uint8Array(encode(frame, cdeEncodeOptions));
|
|
24
|
+
}
|
|
25
|
+
/** A frame that fails schema validation, caught separately from a decode failure so it can be dropped without disconnecting the peer. */
|
|
26
|
+
var SchemaInvalidFrameError = class extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "SchemaInvalidFrameError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** Decodes one message, distinguishing a decode failure (connection-level) from a schema failure (drop this frame, keep the connection) -- mirroring the other adapters' split between the two. */
|
|
33
|
+
function decodeMessage(data) {
|
|
34
|
+
let decoded;
|
|
35
|
+
try {
|
|
36
|
+
decoded = decode(data, cdeDecodeOptions);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw error instanceof Error ? error : /* @__PURE__ */ new Error(`frame body failed to decode: ${String(error)}`);
|
|
39
|
+
}
|
|
40
|
+
const result = frameSchema.safeParse(decoded);
|
|
41
|
+
if (!result.success) throw new SchemaInvalidFrameError(result.error.message);
|
|
42
|
+
return result.data;
|
|
43
|
+
}
|
|
44
|
+
/** Narrows `ws`'s RawData (Buffer | ArrayBuffer | Buffer[]) to a single contiguous, genuinely plain Uint8Array. `ws` only ever delivers an array of buffers when a message arrives fragmented across a stream that itself isn't being reassembled -- doesn't happen for the default (non-streamed) receive path this adapter uses. A Node Buffer IS a Uint8Array subclass, but handing one to cbor2's decode() as-is leaks that subclass into every nested byte-string value decode() returns (it slices the same buffer type it was given); cbor2's encode() then does not recognise a Buffer as a plain byte string and serialises it as a generic object instead, corrupting any value (like a device-id) that round-trips decode -\> store -\> re-encode, as this hub's relay-connect -\> relay-inbound path does. Always copying into a fresh Uint8Array here, the same normalisation the browser/Worker adapters get for free from `new Uint8Array(event.data)` on their own already-ArrayBuffer input, is the fix. */
|
|
45
|
+
function bytesFromRawData(data) {
|
|
46
|
+
if (Array.isArray(data)) throw new Error("expected a single WebSocket message, got fragments");
|
|
47
|
+
return new Uint8Array(data);
|
|
48
|
+
}
|
|
49
|
+
/** Wraps an already-open `ws` WebSocket (client or server side) as a Connection, driven through `ws`'s EventEmitter API (`.on`, not `.addEventListener` -- this repo's other Node-native adapter, tcp-transport.ts, uses the same idiomatic-Node style; `.addEventListener` is reserved for the two adapters wrapping an actual platform WebSocket). */
|
|
50
|
+
function wrapNodeWebSocket(ws) {
|
|
51
|
+
const pending = [];
|
|
52
|
+
const waiters = [];
|
|
53
|
+
let ended = false;
|
|
54
|
+
let failure = null;
|
|
55
|
+
function endAll() {
|
|
56
|
+
ended = true;
|
|
57
|
+
for (const waiter of waiters.splice(0)) waiter.resolve({
|
|
58
|
+
value: void 0,
|
|
59
|
+
done: true
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function failAll(error) {
|
|
63
|
+
failure = error;
|
|
64
|
+
ended = true;
|
|
65
|
+
for (const waiter of waiters.splice(0)) waiter.reject(error);
|
|
66
|
+
}
|
|
67
|
+
ws.on("message", (data, isBinary) => {
|
|
68
|
+
if (!isBinary) {
|
|
69
|
+
failAll(/* @__PURE__ */ new Error("expected a binary WebSocket message"));
|
|
70
|
+
ws.close(CLOSE_PROTOCOL_ERROR, "protocol error");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let frame;
|
|
74
|
+
try {
|
|
75
|
+
frame = decodeMessage(bytesFromRawData(data));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof SchemaInvalidFrameError) return;
|
|
78
|
+
failAll(error instanceof Error ? error : /* @__PURE__ */ new Error(`frame body failed to decode: ${String(error)}`));
|
|
79
|
+
ws.close(CLOSE_PROTOCOL_ERROR, "protocol error");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const waiter = waiters.shift();
|
|
83
|
+
if (waiter) waiter.resolve({
|
|
84
|
+
value: frame,
|
|
85
|
+
done: false
|
|
86
|
+
});
|
|
87
|
+
else pending.push(frame);
|
|
88
|
+
});
|
|
89
|
+
ws.on("close", endAll);
|
|
90
|
+
ws.on("error", endAll);
|
|
91
|
+
const receiveStream = { [Symbol.asyncIterator]() {
|
|
92
|
+
return { async next() {
|
|
93
|
+
const next = pending.shift();
|
|
94
|
+
if (next !== void 0) return {
|
|
95
|
+
value: next,
|
|
96
|
+
done: false
|
|
97
|
+
};
|
|
98
|
+
if (failure !== null) throw failure;
|
|
99
|
+
if (ended) return {
|
|
100
|
+
value: void 0,
|
|
101
|
+
done: true
|
|
102
|
+
};
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
waiters.push({
|
|
105
|
+
resolve,
|
|
106
|
+
reject
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
} };
|
|
110
|
+
} };
|
|
111
|
+
return {
|
|
112
|
+
async send(frame) {
|
|
113
|
+
if (ended) return Promise.reject(/* @__PURE__ */ new Error("connection is closed"));
|
|
114
|
+
ws.send(messageFromFrame(frame));
|
|
115
|
+
return Promise.resolve();
|
|
116
|
+
},
|
|
117
|
+
receive: () => receiveStream,
|
|
118
|
+
async close() {
|
|
119
|
+
ws.close(CLOSE_NORMAL);
|
|
120
|
+
return Promise.resolve();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** A Node `ws`-based Transport: one CBOR frame per binary WebSocket message, served over a plain `http.Server` this adapter owns, or an `https.Server` when `options.tls` is given. `listen()` is a real implementation (unlike the browser adapter, a client can never accept connections) and supports port 0 for an OS-assigned port, reading the real bound address back the same way core's tcp-transport.ts does. */
|
|
125
|
+
function createNodeWebSocketTransport(options = {}) {
|
|
126
|
+
return {
|
|
127
|
+
async connect(address) {
|
|
128
|
+
const url = /^wss?:\/\/./.test(address);
|
|
129
|
+
if (!url && !/^[^/]+:\d+$/.test(address)) throw new Error(`expected "host:port" or a ws:// / wss:// URL, got "${address}"`);
|
|
130
|
+
const target = url ? address : hostPortToWsUrl(address);
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const ws = new WebSocket(target);
|
|
133
|
+
ws.once("open", () => {
|
|
134
|
+
resolve(wrapNodeWebSocket(ws));
|
|
135
|
+
});
|
|
136
|
+
ws.once("error", reject);
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
async listen(address, onConnection) {
|
|
140
|
+
const { host, port } = parseAddress(address);
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const requestHandler = (request, response) => {
|
|
143
|
+
if (options.onHttpRequest) {
|
|
144
|
+
options.onHttpRequest(request, response);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
response.writeHead(HTTP_NOT_FOUND).end();
|
|
148
|
+
};
|
|
149
|
+
const httpServer = options.tls ? createServer$1({
|
|
150
|
+
cert: options.tls.certificatePem,
|
|
151
|
+
key: options.tls.privateKeyPem
|
|
152
|
+
}, requestHandler) : createServer(requestHandler);
|
|
153
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
154
|
+
wss.on("connection", (ws) => {
|
|
155
|
+
onConnection(wrapNodeWebSocket(ws));
|
|
156
|
+
});
|
|
157
|
+
httpServer.once("error", reject);
|
|
158
|
+
httpServer.listen(port, host, () => {
|
|
159
|
+
const bound = httpServer.address();
|
|
160
|
+
if (bound === null || typeof bound === "string") {
|
|
161
|
+
reject(/* @__PURE__ */ new Error("listener did not report a bound address"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
resolve({
|
|
165
|
+
close: async () => new Promise((resolveClose) => {
|
|
166
|
+
wss.close(() => {
|
|
167
|
+
httpServer.close(() => {
|
|
168
|
+
resolveClose();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}),
|
|
172
|
+
address: `${bound.address}:${String(bound.port)}`
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
export { createNodeWebSocketTransport, messageFromFrame, wrapNodeWebSocket };
|
package/dist/server.cjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const require_adapters_node_websocket_transport = require("./adapters/node-websocket-transport.cjs");
|
|
4
|
+
let node_fs = require("node:fs");
|
|
5
|
+
let node_path = require("node:path");
|
|
6
|
+
let node_url = require("node:url");
|
|
7
|
+
let wire_mesh_core_domain_relay_hub = require("wire-mesh-core/domain/relay-hub");
|
|
8
|
+
//#region src/static-console.ts
|
|
9
|
+
const CONTENT_TYPES = {
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
".html": "text/html; charset=utf-8",
|
|
12
|
+
".ico": "image/x-icon",
|
|
13
|
+
".js": "application/javascript; charset=utf-8",
|
|
14
|
+
".json": "application/json; charset=utf-8",
|
|
15
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".svg": "image/svg+xml",
|
|
18
|
+
".webmanifest": "application/manifest+json; charset=utf-8",
|
|
19
|
+
".woff2": "font/woff2"
|
|
20
|
+
};
|
|
21
|
+
const DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
22
|
+
const INDEX_FILE = "index.html";
|
|
23
|
+
const SERVICE_WORKER_ALLOWED_HEADERS = { "Service-Worker-Allowed": "/" };
|
|
24
|
+
/** Resolves `pathname` (a request URL's raw, still percent-encoded path) to a file under `consoleDir`. Returns undefined for a path that decodes to something outside `consoleDir` (a traversal attempt), a malformed percent-encoding, or a real 404 — an extension-bearing path with no matching file. */
|
|
25
|
+
function resolveConsoleFile(consoleDir, pathname) {
|
|
26
|
+
let decoded;
|
|
27
|
+
try {
|
|
28
|
+
decoded = decodeURIComponent(pathname);
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const root = (0, node_path.normalize)(consoleDir);
|
|
33
|
+
const relative = decoded === "/" ? INDEX_FILE : decoded.replace(/^\/+/, "");
|
|
34
|
+
let filePath = (0, node_path.normalize)((0, node_path.join)(root, relative));
|
|
35
|
+
if (!filePath.startsWith(root + node_path.sep) && filePath !== root) return;
|
|
36
|
+
if (!(0, node_fs.existsSync)(filePath) || !(0, node_fs.statSync)(filePath).isFile()) {
|
|
37
|
+
if ((0, node_path.extname)(decoded) !== "") return;
|
|
38
|
+
filePath = (0, node_path.join)(root, INDEX_FILE);
|
|
39
|
+
if (!(0, node_fs.existsSync)(filePath) || !(0, node_fs.statSync)(filePath).isFile()) return;
|
|
40
|
+
}
|
|
41
|
+
const contentType = CONTENT_TYPES[(0, node_path.extname)(filePath)] ?? DEFAULT_CONTENT_TYPE;
|
|
42
|
+
return {
|
|
43
|
+
body: (0, node_fs.readFileSync)(filePath),
|
|
44
|
+
contentType,
|
|
45
|
+
...filePath.endsWith("sw.js") ? { headers: SERVICE_WORKER_ALLOWED_HEADERS } : {}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/server.ts
|
|
50
|
+
function healthResponse() {
|
|
51
|
+
return {
|
|
52
|
+
ok: true,
|
|
53
|
+
node: "wire-mesh",
|
|
54
|
+
roles: ["relay"]
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const DEFAULT_BIND_ADDRESS = "0.0.0.0:8787";
|
|
58
|
+
const HTTP_OK = 200;
|
|
59
|
+
const HTTP_NOT_FOUND = 404;
|
|
60
|
+
const HEALTH_PATH = "/health";
|
|
61
|
+
const __dirname$1 = (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
62
|
+
const CONSOLE_DIR = (0, node_path.join)(__dirname$1, "web-console");
|
|
63
|
+
function bindAddressFromArgs(argv) {
|
|
64
|
+
const flagIndex = argv.indexOf("--bind");
|
|
65
|
+
return (flagIndex === -1 ? void 0 : argv[flagIndex + 1]) ?? DEFAULT_BIND_ADDRESS;
|
|
66
|
+
}
|
|
67
|
+
/** Reads --tls-cert/--tls-key from argv, required together — a self-hosted node answering wss:// (wire-mesh#184, so an https://-served PWA can reach it without mixed-content blocking) needs an operator-supplied certificate; this package generates none of its own, matching the README's security note. Returns undefined when neither flag is given, the existing plain-ws:// default. */
|
|
68
|
+
function tlsFromArgs(argv) {
|
|
69
|
+
const certIndex = argv.indexOf("--tls-cert");
|
|
70
|
+
const keyIndex = argv.indexOf("--tls-key");
|
|
71
|
+
const certPath = certIndex === -1 ? void 0 : argv[certIndex + 1];
|
|
72
|
+
const keyPath = keyIndex === -1 ? void 0 : argv[keyIndex + 1];
|
|
73
|
+
if (certPath === void 0 && keyPath === void 0) return;
|
|
74
|
+
if (certPath === void 0 || keyPath === void 0) throw new Error("--tls-cert and --tls-key must be given together");
|
|
75
|
+
return {
|
|
76
|
+
certPath,
|
|
77
|
+
keyPath
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Builds the listener's onHttpRequest handler: /health answers the same JSON shape healthResponse() returns, everything else resolves against consoleDir via resolveConsoleFile. Takes consoleDir as a parameter, not a module-level constant, so a test can point it at a temp fixture instead of the real build-time CONSOLE_DIR. */
|
|
81
|
+
function createHttpRequestHandler(consoleDir) {
|
|
82
|
+
return (request, response) => {
|
|
83
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
84
|
+
if (url.pathname === HEALTH_PATH) {
|
|
85
|
+
response.writeHead(HTTP_OK, { "content-type": "application/json" });
|
|
86
|
+
response.end(JSON.stringify(healthResponse()));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const file = resolveConsoleFile(consoleDir, url.pathname);
|
|
90
|
+
if (file) {
|
|
91
|
+
response.writeHead(HTTP_OK, {
|
|
92
|
+
"content-type": file.contentType,
|
|
93
|
+
...file.headers
|
|
94
|
+
});
|
|
95
|
+
response.end(file.body);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
response.writeHead(HTTP_NOT_FOUND).end();
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function logStartup(message) {
|
|
102
|
+
console.log(message);
|
|
103
|
+
}
|
|
104
|
+
async function main() {
|
|
105
|
+
const argv = process.argv.slice(2);
|
|
106
|
+
const tlsFiles = tlsFromArgs(argv);
|
|
107
|
+
const tls = tlsFiles ? {
|
|
108
|
+
certificatePem: (0, node_fs.readFileSync)(tlsFiles.certPath, "utf-8"),
|
|
109
|
+
privateKeyPem: (0, node_fs.readFileSync)(tlsFiles.keyPath, "utf-8")
|
|
110
|
+
} : void 0;
|
|
111
|
+
const hub = (0, wire_mesh_core_domain_relay_hub.createRelayHub)();
|
|
112
|
+
const listener = await require_adapters_node_websocket_transport.createNodeWebSocketTransport({
|
|
113
|
+
onHttpRequest: createHttpRequestHandler(CONSOLE_DIR),
|
|
114
|
+
...tls ? { tls } : {}
|
|
115
|
+
}).listen(bindAddressFromArgs(argv), (connection) => {
|
|
116
|
+
hub.handleConnection(connection);
|
|
117
|
+
});
|
|
118
|
+
logStartup(`wire-mesh listening on ${tls ? "wss" : "ws"}://${listener.address}`);
|
|
119
|
+
}
|
|
120
|
+
const invokedPath = process.argv[1];
|
|
121
|
+
if (invokedPath !== void 0 && require("url").pathToFileURL(__filename).href === `file://${invokedPath}`) main();
|
|
122
|
+
//#endregion
|
|
123
|
+
exports.bindAddressFromArgs = bindAddressFromArgs;
|
|
124
|
+
exports.createHttpRequestHandler = createHttpRequestHandler;
|
|
125
|
+
exports.healthResponse = healthResponse;
|
|
126
|
+
exports.tlsFromArgs = tlsFromArgs;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
//#region src/server.d.ts
|
|
3
|
+
export declare function healthResponse(): {
|
|
4
|
+
ok: true;
|
|
5
|
+
node: string;
|
|
6
|
+
roles: string[];
|
|
7
|
+
};
|
|
8
|
+
export declare function bindAddressFromArgs(argv: readonly string[]): string;
|
|
9
|
+
export interface TlsFilePaths {
|
|
10
|
+
certPath: string;
|
|
11
|
+
keyPath: string;
|
|
12
|
+
}
|
|
13
|
+
/** Reads --tls-cert/--tls-key from argv, required together — a self-hosted node answering wss:// (wire-mesh#184, so an https://-served PWA can reach it without mixed-content blocking) needs an operator-supplied certificate; this package generates none of its own, matching the README's security note. Returns undefined when neither flag is given, the existing plain-ws:// default. */
|
|
14
|
+
export declare function tlsFromArgs(argv: readonly string[]): TlsFilePaths | undefined;
|
|
15
|
+
/** Builds the listener's onHttpRequest handler: /health answers the same JSON shape healthResponse() returns, everything else resolves against consoleDir via resolveConsoleFile. Takes consoleDir as a parameter, not a module-level constant, so a test can point it at a temp fixture instead of the real build-time CONSOLE_DIR. */
|
|
16
|
+
export declare function createHttpRequestHandler(consoleDir: string): (request: IncomingMessage, response: ServerResponse) => void;
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
//#region src/server.d.ts
|
|
3
|
+
export declare function healthResponse(): {
|
|
4
|
+
ok: true;
|
|
5
|
+
node: string;
|
|
6
|
+
roles: string[];
|
|
7
|
+
};
|
|
8
|
+
export declare function bindAddressFromArgs(argv: readonly string[]): string;
|
|
9
|
+
export interface TlsFilePaths {
|
|
10
|
+
certPath: string;
|
|
11
|
+
keyPath: string;
|
|
12
|
+
}
|
|
13
|
+
/** Reads --tls-cert/--tls-key from argv, required together — a self-hosted node answering wss:// (wire-mesh#184, so an https://-served PWA can reach it without mixed-content blocking) needs an operator-supplied certificate; this package generates none of its own, matching the README's security note. Returns undefined when neither flag is given, the existing plain-ws:// default. */
|
|
14
|
+
export declare function tlsFromArgs(argv: readonly string[]): TlsFilePaths | undefined;
|
|
15
|
+
/** Builds the listener's onHttpRequest handler: /health answers the same JSON shape healthResponse() returns, everything else resolves against consoleDir via resolveConsoleFile. Takes consoleDir as a parameter, not a module-level constant, so a test can point it at a temp fixture instead of the real build-time CONSOLE_DIR. */
|
|
16
|
+
export declare function createHttpRequestHandler(consoleDir: string): (request: IncomingMessage, response: ServerResponse) => void;
|
|
17
|
+
//#endregion
|