@mh-gg/host-ipc 0.1.1-alpha.20260613T085325975Z

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/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@mh-gg/host-ipc",
3
+ "version": "0.1.1-alpha.20260613T085325975Z",
4
+ "description": "Relay IPC framing helpers for matterhorn hosts.",
5
+ "type": "commonjs",
6
+ "main": "src/index.cjs",
7
+ "exports": {
8
+ ".": "./src/index.cjs"
9
+ },
10
+ "dependencies": {
11
+ "@mh-gg/relay-core": "^0.1.1-alpha.20260613T085325975Z"
12
+ },
13
+ "engines": {
14
+ "node": ">=22.12"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test test/*.test.cjs",
18
+ "coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-include=src/*.cjs test/*.test.cjs"
19
+ }
20
+ }
package/src/index.cjs ADDED
@@ -0,0 +1,198 @@
1
+ const { createRelayChunkAssembler, messageJsonByteLength, sendMessage } = require("@mh-gg/relay-core");
2
+
3
+ const OPEN_READY_STATE = 1;
4
+ const relaySocketConnections = new WeakMap();
5
+
6
+ function relaySocketConnection(socket) {
7
+ let conn = relaySocketConnections.get(socket);
8
+ if (conn) return conn;
9
+ conn = {
10
+ get open() {
11
+ return socket.readyState === OPEN_READY_STATE;
12
+ },
13
+ send(message) {
14
+ if (socket.readyState !== OPEN_READY_STATE) return;
15
+ socket.send(JSON.stringify(message));
16
+ }
17
+ };
18
+ relaySocketConnections.set(socket, conn);
19
+ return conn;
20
+ }
21
+
22
+ function sendToRelay(socket, message) {
23
+ sendMessage(relaySocketConnection(socket), message);
24
+ }
25
+
26
+ function hostDebugEnabled() {
27
+ return process.env.MATTERHORN_DEBUG === "1";
28
+ }
29
+
30
+ function record(value) {
31
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value) ? value : {};
32
+ }
33
+
34
+ function safeDebugDetails(peerId, message) {
35
+ const value = record(message);
36
+ const details = {
37
+ peerId,
38
+ messageType: typeof value.type === "string" ? value.type : undefined,
39
+ keys: Object.keys(value).sort()
40
+ };
41
+ for (const key of ["roomName", "clientId", "frontendId", "operationId", "acceptedMutationId", "code"]) {
42
+ if (typeof value[key] === "string") details[key] = value[key];
43
+ }
44
+ for (const key of ["offset", "byteLength", "totalBytes"]) {
45
+ if (Number.isFinite(value[key])) details[key] = value[key];
46
+ }
47
+ if (typeof value.done === "boolean") details.done = value.done;
48
+ return details;
49
+ }
50
+
51
+ function hostDebugMessage(peerId, action, message) {
52
+ const details = safeDebugDetails(peerId, message);
53
+ return {
54
+ type: "host/debug",
55
+ protocol: 1,
56
+ scope: "host",
57
+ message: `${action} ${details.messageType || "message"}`,
58
+ details,
59
+ createdAt: Date.now()
60
+ };
61
+ }
62
+
63
+ function mirrorsHostDebug(message) {
64
+ return hostDebugEnabled()
65
+ && message?.type !== "host/debug"
66
+ && message?.type !== "host/frontend-chunk";
67
+ }
68
+
69
+ function sendToClient(socket, peerId, message) {
70
+ if (mirrorsHostDebug(message)) {
71
+ sendToRelay(socket, {
72
+ type: "host.send",
73
+ peerId,
74
+ message: hostDebugMessage(peerId, "host.send", message)
75
+ });
76
+ }
77
+ sendToRelay(socket, {
78
+ type: "host.send",
79
+ peerId,
80
+ message
81
+ });
82
+ }
83
+
84
+ function closeClient(socket, peerId) {
85
+ if (hostDebugEnabled()) {
86
+ sendToRelay(socket, {
87
+ type: "host.send",
88
+ peerId,
89
+ message: hostDebugMessage(peerId, "host.close", { type: "host.close" })
90
+ });
91
+ }
92
+ sendToRelay(socket, {
93
+ type: "host.close",
94
+ peerId
95
+ });
96
+ }
97
+
98
+ function parseRelayMessage(data) {
99
+ try {
100
+ return JSON.parse(data.toString("utf8"));
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ function dataByteLength(data) {
107
+ if (typeof data === "string") return Buffer.byteLength(data, "utf8");
108
+ if (Buffer.isBuffer(data)) return data.length;
109
+ if (Array.isArray(data)) return data.reduce((total, item) => total + dataByteLength(item), 0);
110
+ return Buffer.byteLength(String(data || ""), "utf8");
111
+ }
112
+
113
+ function createRelayMessageReceiver(options = {}) {
114
+ const assembler = createRelayChunkAssembler(options);
115
+ const maxMessageBytes = Number.isInteger(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : undefined;
116
+ return function receiveRelayMessage(data) {
117
+ if (maxMessageBytes && dataByteLength(data) > maxMessageBytes) return undefined;
118
+ const message = typeof data === "object" && !Buffer.isBuffer(data) ? data : parseRelayMessage(data);
119
+ if (!message) return undefined;
120
+ return assembler.accept(message);
121
+ };
122
+ }
123
+
124
+ let ipcClientSeq = 0;
125
+
126
+ class WebSocketRelayConnection {
127
+ constructor(socket, options = {}) {
128
+ this.socket = socket;
129
+ this.open = true;
130
+ this.peer = `ipc_${++ipcClientSeq}`;
131
+ this.maxMessageBytes = Number.isInteger(options.maxMessageBytes) && options.maxMessageBytes > 0
132
+ ? options.maxMessageBytes
133
+ : undefined;
134
+ this.receiveRelayMessage = createRelayMessageReceiver({ maxBytes: this.maxMessageBytes });
135
+ this.handlers = {
136
+ data: [],
137
+ close: [],
138
+ error: []
139
+ };
140
+
141
+ socket.on("message", (data) => {
142
+ if (this.maxMessageBytes && dataByteLength(data) > this.maxMessageBytes) {
143
+ this.send({
144
+ type: "host.message.error",
145
+ code: "message-too-large",
146
+ message: "Host IPC message is too large"
147
+ });
148
+ this.close();
149
+ return;
150
+ }
151
+ const message = this.receiveRelayMessage(data);
152
+ if (!message) {
153
+ if (dataByteLength(data) > 0 && !parseRelayMessage(data)) this.send(["NOTICE", "Invalid JSON"]);
154
+ return;
155
+ }
156
+ const byteLength = messageJsonByteLength(message);
157
+ if (this.maxMessageBytes && (byteLength === undefined || byteLength > this.maxMessageBytes)) {
158
+ this.send({
159
+ type: "host.message.error",
160
+ code: "message-too-large",
161
+ message: "Host IPC message is too large"
162
+ });
163
+ return;
164
+ }
165
+ for (const handler of this.handlers.data) handler(message);
166
+ });
167
+ socket.on("close", () => {
168
+ this.open = false;
169
+ for (const handler of this.handlers.close) handler();
170
+ });
171
+ socket.on("error", (error) => {
172
+ this.open = false;
173
+ for (const handler of this.handlers.error) handler(error);
174
+ });
175
+ }
176
+
177
+ send(message) {
178
+ sendToRelay(this.socket, message);
179
+ }
180
+
181
+ close() {
182
+ this.socket.close();
183
+ }
184
+
185
+ on(event, callback) {
186
+ this.handlers[event]?.push(callback);
187
+ }
188
+ }
189
+
190
+ module.exports = {
191
+ WebSocketRelayConnection,
192
+ closeClient,
193
+ createRelayMessageReceiver,
194
+ hostDebugMessage,
195
+ parseRelayMessage,
196
+ sendToClient,
197
+ sendToRelay
198
+ };
@@ -0,0 +1,193 @@
1
+ const assert = require("node:assert/strict");
2
+ const { EventEmitter } = require("node:events");
3
+ const test = require("node:test");
4
+ const { WebSocketRelayConnection, closeClient, createRelayMessageReceiver, parseRelayMessage, sendToClient, sendToRelay } = require("..");
5
+
6
+ function socket(readyState = 1) {
7
+ return {
8
+ readyState,
9
+ sent: [],
10
+ send(raw) {
11
+ this.sent.push(raw);
12
+ }
13
+ };
14
+ }
15
+
16
+ test("sends relay messages only while the socket is open", () => {
17
+ const open = socket();
18
+ const closed = socket(3);
19
+ sendToRelay(open, { type: "host.register" });
20
+ sendToRelay(closed, { type: "host.register" });
21
+
22
+ assert.deepEqual(open.sent.map((raw) => JSON.parse(raw)), [{ type: "host.register" }]);
23
+ assert.deepEqual(closed.sent, []);
24
+ });
25
+
26
+ test("sendToRelay keeps small IPC messages direct and chunks large messages", async () => {
27
+ const open = socket();
28
+ sendToRelay(open, { type: "host.register" });
29
+ assert.deepEqual(open.sent.map((raw) => JSON.parse(raw)), [{ type: "host.register" }]);
30
+
31
+ open.sent.length = 0;
32
+ sendToRelay(open, { type: "host.snapshot", store: { body: "x".repeat(12 * 1024) } });
33
+ await new Promise((resolve) => setImmediate(resolve));
34
+
35
+ const sent = open.sent.map((raw) => JSON.parse(raw));
36
+ assert.equal(sent.every((message) => message.type === "relay.chunk"), true);
37
+ const receive = createRelayMessageReceiver();
38
+ const received = sent.map((message) => receive(message)).find(Boolean);
39
+ assert.equal(received.type, "host.snapshot");
40
+ assert.equal(received.store.body.length, 12 * 1024);
41
+ });
42
+
43
+ test("frames host send and close messages", () => {
44
+ const relaySocket = socket();
45
+ sendToClient(relaySocket, "peer-1", { type: "host/welcome" });
46
+ closeClient(relaySocket, "peer-1");
47
+
48
+ assert.deepEqual(relaySocket.sent.map((raw) => JSON.parse(raw)), [
49
+ { type: "host.send", peerId: "peer-1", message: { type: "host/welcome" } },
50
+ { type: "host.close", peerId: "peer-1" }
51
+ ]);
52
+ });
53
+
54
+ test("mirrors safe host debug messages when host debug mode is enabled", () => {
55
+ const previous = process.env.MATTERHORN_DEBUG;
56
+ process.env.MATTERHORN_DEBUG = "1";
57
+ try {
58
+ const relaySocket = socket();
59
+ sendToClient(relaySocket, "peer-1", {
60
+ type: "host/welcome",
61
+ protocol: 1,
62
+ roomName: "team-chat",
63
+ clientId: "client-1",
64
+ adminToken: "secret-admin-token",
65
+ memberCredentials: { memberSecret: "secret-member-token" }
66
+ });
67
+ closeClient(relaySocket, "peer-1");
68
+ const sent = relaySocket.sent.map((raw) => JSON.parse(raw));
69
+
70
+ assert.equal(sent[0].message.type, "host/debug");
71
+ assert.equal(sent[0].message.message, "host.send host/welcome");
72
+ assert.deepEqual(sent[0].message.details.peerId, "peer-1");
73
+ assert.deepEqual(sent[0].message.details.roomName, "team-chat");
74
+ assert.deepEqual(sent[0].message.details.clientId, "client-1");
75
+ assert.equal(JSON.stringify(sent[0].message).includes("secret-admin-token"), false);
76
+ assert.equal(JSON.stringify(sent[0].message).includes("secret-member-token"), false);
77
+ assert.deepEqual(sent[1].message.type, "host/welcome");
78
+ assert.equal(sent[2].message.type, "host/debug");
79
+ assert.equal(sent[2].message.message, "host.close host.close");
80
+ assert.equal(sent[3].type, "host.close");
81
+ } finally {
82
+ if (previous === undefined) delete process.env.MATTERHORN_DEBUG;
83
+ else process.env.MATTERHORN_DEBUG = previous;
84
+ }
85
+ });
86
+
87
+ test("does not mirror per-chunk host debug messages", () => {
88
+ const previous = process.env.MATTERHORN_DEBUG;
89
+ process.env.MATTERHORN_DEBUG = "1";
90
+ try {
91
+ const relaySocket = socket();
92
+ sendToClient(relaySocket, "peer-1", {
93
+ type: "host/frontend-chunk",
94
+ protocol: 1,
95
+ roomName: "team-chat",
96
+ frontendId: "player",
97
+ offset: 0,
98
+ byteLength: 4,
99
+ totalBytes: 4,
100
+ data: "YWJjZA==",
101
+ done: true
102
+ });
103
+
104
+ assert.deepEqual(relaySocket.sent.map((raw) => JSON.parse(raw)), [{
105
+ type: "host.send",
106
+ peerId: "peer-1",
107
+ message: {
108
+ type: "host/frontend-chunk",
109
+ protocol: 1,
110
+ roomName: "team-chat",
111
+ frontendId: "player",
112
+ offset: 0,
113
+ byteLength: 4,
114
+ totalBytes: 4,
115
+ data: "YWJjZA==",
116
+ done: true
117
+ }
118
+ }]);
119
+ } finally {
120
+ if (previous === undefined) delete process.env.MATTERHORN_DEBUG;
121
+ else process.env.MATTERHORN_DEBUG = previous;
122
+ }
123
+ });
124
+
125
+ test("parses relay JSON and ignores malformed data", () => {
126
+ assert.deepEqual(parseRelayMessage(Buffer.from('{"type":"relay/client-close"}')), { type: "relay/client-close" });
127
+ assert.equal(parseRelayMessage(Buffer.from("{nope")), undefined);
128
+ });
129
+
130
+ class FakeWebSocket extends EventEmitter {
131
+ constructor() {
132
+ super();
133
+ this.readyState = 1;
134
+ this.sent = [];
135
+ this.closed = false;
136
+ }
137
+
138
+ send(raw) {
139
+ this.sent.push(raw);
140
+ }
141
+
142
+ close() {
143
+ this.closed = true;
144
+ this.readyState = 3;
145
+ this.emit("close");
146
+ }
147
+ }
148
+
149
+ test("WebSocketRelayConnection parses messages and tracks lifecycle", () => {
150
+ const socket = new FakeWebSocket();
151
+ const conn = new WebSocketRelayConnection(socket);
152
+ const messages = [];
153
+ const errors = [];
154
+ let closed = false;
155
+ conn.on("data", (message) => messages.push(message));
156
+ conn.on("error", (error) => errors.push(error.message));
157
+ conn.on("close", () => {
158
+ closed = true;
159
+ });
160
+
161
+ socket.emit("message", Buffer.from('{"type":"client/hello"}'));
162
+ socket.emit("message", Buffer.from("{nope"));
163
+ conn.send({ type: "host/welcome" });
164
+ socket.emit("error", new Error("socket failed"));
165
+ conn.close();
166
+
167
+ assert.match(conn.peer, /^ipc_/);
168
+ assert.deepEqual(messages, [{ type: "client/hello" }]);
169
+ assert.deepEqual(socket.sent.map((raw) => JSON.parse(raw)), [
170
+ ["NOTICE", "Invalid JSON"],
171
+ { type: "host/welcome" }
172
+ ]);
173
+ assert.deepEqual(errors, ["socket failed"]);
174
+ assert.equal(closed, true);
175
+ assert.equal(conn.open, false);
176
+ });
177
+
178
+ test("WebSocketRelayConnection rejects oversized messages before dispatch", () => {
179
+ const socket = new FakeWebSocket();
180
+ const conn = new WebSocketRelayConnection(socket, { maxMessageBytes: 16 });
181
+ const messages = [];
182
+ conn.on("data", (message) => messages.push(message));
183
+
184
+ socket.emit("message", Buffer.from('{"type":"client/hello","padding":"too-large"}'));
185
+
186
+ assert.deepEqual(messages, []);
187
+ assert.deepEqual(socket.sent.map((raw) => JSON.parse(raw)), [{
188
+ type: "host.message.error",
189
+ code: "message-too-large",
190
+ message: "Host IPC message is too large"
191
+ }]);
192
+ assert.equal(socket.closed, true);
193
+ });