@mh-gg/host 0.1.1-alpha.20260626T104441232Z
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/LICENSE +21 -0
- package/README.md +3 -0
- package/package.json +35 -0
- package/src/host.cjs +150 -0
- package/src/hostAnnouncement.cjs +71 -0
- package/src/hostClients/constants.cjs +7 -0
- package/src/hostClients/frontendBundleManifest.cjs +29 -0
- package/src/hostClients/frontendBundleRefresh.cjs +246 -0
- package/src/hostClients/frontendCacheStatus.cjs +39 -0
- package/src/hostClients/frontendChunkRequests.cjs +124 -0
- package/src/hostClients/frontendChunkSizing.cjs +14 -0
- package/src/hostClients/frontendManifestFiles.cjs +17 -0
- package/src/hostClients/frontendRequests.cjs +116 -0
- package/src/hostClients/frontendSelection.cjs +21 -0
- package/src/hostClients/frontendTransferMetrics.cjs +111 -0
- package/src/hostClients/frontendUnavailable.cjs +15 -0
- package/src/hostClients/index.cjs +68 -0
- package/src/hostClients/rejections.cjs +37 -0
- package/src/hostClients/sourceMtime.cjs +68 -0
- package/src/hostSession.cjs +217 -0
- package/src/index.cjs +24 -0
- package/src/inlineProgressBar.cjs +128 -0
- package/src/localRelayClient.cjs +153 -0
- package/src/metrics.cjs +30 -0
- package/src/trustedRelays.cjs +158 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const { PROTOCOL } = require("@mh-gg/host-config");
|
|
3
|
+
const { sendToClient } = require("@mh-gg/host-ipc");
|
|
4
|
+
const { frontendChunkLength } = require("./frontendChunkSizing.cjs");
|
|
5
|
+
const { frontendManifestFile } = require("./frontendManifestFiles.cjs");
|
|
6
|
+
const { frontendBundleFile: findFrontendBundleFile } = require("./frontendSelection.cjs");
|
|
7
|
+
const { nowMs } = require("./frontendTransferMetrics.cjs");
|
|
8
|
+
|
|
9
|
+
function readFrontendChunk(file, offset, byteLength) {
|
|
10
|
+
const buffer = byteLength > 0 ? Buffer.allocUnsafe(byteLength) : Buffer.alloc(0);
|
|
11
|
+
if (byteLength <= 0) return buffer;
|
|
12
|
+
const fd = fs.openSync(file, "r");
|
|
13
|
+
try {
|
|
14
|
+
fs.readSync(fd, buffer, 0, byteLength, offset);
|
|
15
|
+
} finally {
|
|
16
|
+
fs.closeSync(fd);
|
|
17
|
+
}
|
|
18
|
+
return buffer;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function dynamicBundleSnapshot(frontendBundle, message) {
|
|
22
|
+
if (message.path || typeof message.integrity !== "string") return undefined;
|
|
23
|
+
const snapshot = frontendBundle?.dynamicBundleSnapshots?.get(message.integrity);
|
|
24
|
+
if (!snapshot) return undefined;
|
|
25
|
+
if (message.frontendId && snapshot.id !== message.frontendId) return undefined;
|
|
26
|
+
return snapshot;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readSnapshotChunk(snapshot, offset, byteLength) {
|
|
30
|
+
if (byteLength <= 0) return Buffer.alloc(0);
|
|
31
|
+
return snapshot.buffer.subarray(offset, offset + byteLength);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function frontendChunkResponse({ data, frontend, message, offset, byteLength, totalBytes, store }) {
|
|
35
|
+
return {
|
|
36
|
+
type: "host/frontend-chunk",
|
|
37
|
+
protocol: PROTOCOL,
|
|
38
|
+
roomName: store.roomName,
|
|
39
|
+
frontendId: frontend.id,
|
|
40
|
+
integrity: frontend.integrity,
|
|
41
|
+
...(message.path ? { path: message.path } : {}),
|
|
42
|
+
offset,
|
|
43
|
+
byteLength,
|
|
44
|
+
totalBytes,
|
|
45
|
+
encoding: "base64",
|
|
46
|
+
data,
|
|
47
|
+
done: offset + byteLength >= totalBytes
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createFrontendChunkRequestHandler({ chunkProgress, frontendBundle, frontendForRequest, frontendTransferMetrics, rejectRoomMismatch, sendFrontendUnavailable, store }) {
|
|
52
|
+
return function handleFrontendChunkRequest(socket, peerId, message) {
|
|
53
|
+
const startedAt = nowMs();
|
|
54
|
+
if (message.protocol !== PROTOCOL || message.roomName !== store.roomName) {
|
|
55
|
+
rejectRoomMismatch(socket, peerId, false);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const lookupStartedAt = nowMs();
|
|
59
|
+
const snapshot = dynamicBundleSnapshot(frontendBundle, message);
|
|
60
|
+
const frontend = snapshot
|
|
61
|
+
? { id: snapshot.id, integrity: snapshot.integrity, byteLength: snapshot.byteLength, chunkSize: snapshot.chunkSize, name: snapshot.name }
|
|
62
|
+
: frontendForRequest(message, { refreshDynamic: false });
|
|
63
|
+
const lookupMs = nowMs() - lookupStartedAt;
|
|
64
|
+
if (!frontend) {
|
|
65
|
+
sendFrontendUnavailable(socket, peerId);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let statMs = 0;
|
|
70
|
+
let totalBytes;
|
|
71
|
+
let buffer;
|
|
72
|
+
const offset = message.offset || 0;
|
|
73
|
+
if (snapshot) {
|
|
74
|
+
totalBytes = snapshot.byteLength;
|
|
75
|
+
const byteLength = frontendChunkLength({ requestedLength: message.length, frontendChunkSize: frontend.chunkSize, remainingBytes: totalBytes - offset });
|
|
76
|
+
const readStartedAt = nowMs();
|
|
77
|
+
buffer = readSnapshotChunk(snapshot, offset, byteLength);
|
|
78
|
+
const readMs = nowMs() - readStartedAt;
|
|
79
|
+
const encodeStartedAt = nowMs();
|
|
80
|
+
const data = buffer.toString("base64");
|
|
81
|
+
const encodeMs = nowMs() - encodeStartedAt;
|
|
82
|
+
const sendStartedAt = nowMs();
|
|
83
|
+
sendToClient(socket, peerId, frontendChunkResponse({ data, frontend, message, offset, byteLength, totalBytes, store }));
|
|
84
|
+
const transferMetric = frontendTransferMetrics.record({
|
|
85
|
+
peerId, frontend, message, offset, byteLength, totalBytes, lookupMs, statMs, readMs,
|
|
86
|
+
encodeMs, sendMs: nowMs() - sendStartedAt, handlerMs: nowMs() - startedAt
|
|
87
|
+
});
|
|
88
|
+
chunkProgress.update({ completed: transferMetric.completed, total: totalBytes, currentItem: frontend.name || frontend.id });
|
|
89
|
+
if (transferMetric.complete) chunkProgress.stop();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const file = frontendManifestFile({ frontend, frontendBundle, bundleFile: findFrontendBundleFile(frontend, frontendBundle), filePath: message.path });
|
|
94
|
+
if (!file || !fs.existsSync(file)) {
|
|
95
|
+
sendFrontendUnavailable(socket, peerId);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const statStartedAt = nowMs();
|
|
99
|
+
const stat = fs.statSync(file);
|
|
100
|
+
statMs = nowMs() - statStartedAt;
|
|
101
|
+
totalBytes = stat.size;
|
|
102
|
+
const byteLength = frontendChunkLength({ requestedLength: message.length, frontendChunkSize: frontend.chunkSize, remainingBytes: totalBytes - offset });
|
|
103
|
+
const readStartedAt = nowMs();
|
|
104
|
+
buffer = readFrontendChunk(file, offset, byteLength);
|
|
105
|
+
const readMs = nowMs() - readStartedAt;
|
|
106
|
+
const encodeStartedAt = nowMs();
|
|
107
|
+
const data = buffer.toString("base64");
|
|
108
|
+
const encodeMs = nowMs() - encodeStartedAt;
|
|
109
|
+
const sendStartedAt = nowMs();
|
|
110
|
+
sendToClient(socket, peerId, frontendChunkResponse({ data, frontend, message, offset, byteLength, totalBytes, store }));
|
|
111
|
+
const transferMetric = frontendTransferMetrics.record({
|
|
112
|
+
peerId, frontend, message, offset, byteLength, totalBytes, lookupMs, statMs, readMs,
|
|
113
|
+
encodeMs, sendMs: nowMs() - sendStartedAt, handlerMs: nowMs() - startedAt
|
|
114
|
+
});
|
|
115
|
+
chunkProgress.update({ completed: transferMetric.completed, total: totalBytes, currentItem: frontend.name || frontend.id });
|
|
116
|
+
if (transferMetric.complete) chunkProgress.stop();
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
createFrontendChunkRequestHandler,
|
|
122
|
+
frontendChunkResponse,
|
|
123
|
+
readFrontendChunk
|
|
124
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const { DEFAULT_FRONTEND_CHUNK_BYTES } = require("./constants.cjs");
|
|
2
|
+
|
|
3
|
+
const DEFAULT_FRONTEND_BULK_CHUNK_BYTES = 128 * 1024;
|
|
4
|
+
|
|
5
|
+
function frontendChunkLength({ requestedLength, frontendChunkSize, remainingBytes }) {
|
|
6
|
+
const requested = requestedLength || frontendChunkSize || DEFAULT_FRONTEND_CHUNK_BYTES;
|
|
7
|
+
const maxLength = frontendChunkSize || DEFAULT_FRONTEND_BULK_CHUNK_BYTES;
|
|
8
|
+
return Math.max(0, Math.min(Math.max(1, requested), maxLength, remainingBytes));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
DEFAULT_FRONTEND_BULK_CHUNK_BYTES,
|
|
13
|
+
frontendChunkLength
|
|
14
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
|
|
3
|
+
function frontendManifestFile({ frontend, frontendBundle, bundleFile, filePath }) {
|
|
4
|
+
if (!filePath) return bundleFile;
|
|
5
|
+
const manifest = frontend.manifest || frontendBundle?.manifest;
|
|
6
|
+
const files = Array.isArray(manifest?.files) ? manifest.files : [];
|
|
7
|
+
if (!files.some((candidate) => candidate?.path === filePath)) return undefined;
|
|
8
|
+
const base = path.dirname(bundleFile || frontendBundle?.manifestFile || "");
|
|
9
|
+
if (!base) return undefined;
|
|
10
|
+
const file = path.resolve(base, filePath);
|
|
11
|
+
if (file !== base && !file.startsWith(base + path.sep)) return undefined;
|
|
12
|
+
return file;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
frontendManifestFile
|
|
17
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const { PROTOCOL } = require("@mh-gg/host-config");
|
|
2
|
+
const { sendToClient } = require("@mh-gg/host-ipc");
|
|
3
|
+
const { frontendCacheStatus, logFrontendCacheStatus } = require("./frontendCacheStatus.cjs");
|
|
4
|
+
const { createDynamicFrontendRefresher } = require("./frontendBundleRefresh.cjs");
|
|
5
|
+
const { frontendBundleFile: findFrontendBundleFile, frontendForMessage } = require("./frontendSelection.cjs");
|
|
6
|
+
const { createFrontendTransferMetrics } = require("./frontendTransferMetrics.cjs");
|
|
7
|
+
const { sendFrontendUnavailable } = require("./frontendUnavailable.cjs");
|
|
8
|
+
const { createInlineProgressLoader } = require("../inlineProgressBar.cjs");
|
|
9
|
+
const { createFrontendChunkRequestHandler } = require("./frontendChunkRequests.cjs");
|
|
10
|
+
|
|
11
|
+
function ensureValidProtocolAndRoom(store, socket, peerId, message, rejectRoomMismatch) {
|
|
12
|
+
if (message.protocol !== PROTOCOL || message.roomName !== store.roomName) {
|
|
13
|
+
rejectRoomMismatch(socket, peerId, false);
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveFrontendForManifestResponse(store, frontendForRequest, message, dynamicFrontend) {
|
|
20
|
+
const refresh = message?.protocol === PROTOCOL;
|
|
21
|
+
if (refresh && message) dynamicFrontend.refreshDynamicFrontends();
|
|
22
|
+
return frontendForRequest(message, { refreshDynamic: refresh });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createRoomInfoResponsePayload(store, cache, message, frontend) {
|
|
26
|
+
const response = {
|
|
27
|
+
type: "room.info",
|
|
28
|
+
protocol: PROTOCOL,
|
|
29
|
+
roomName: store.roomName,
|
|
30
|
+
runtimeMode: store.runtimeMode,
|
|
31
|
+
roomMetadata: store.roomMetadata,
|
|
32
|
+
roomApp: store.roomApp
|
|
33
|
+
};
|
|
34
|
+
if (message.clientId) response.clientId = message.clientId;
|
|
35
|
+
if (cache) response.frontendCache = cache;
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createFrontendRequestHandlers({ store, frontendBundle, logger = console, rejectRoomMismatch, onRoomAppChange = () => {}, frontendProgress, progressStream }) {
|
|
40
|
+
const dynamicFrontend = createDynamicFrontendRefresher({ store, frontendBundle, onRoomAppChange });
|
|
41
|
+
const chunkProgress = frontendProgress || createInlineProgressLoader("Loading frontend chunks...", {
|
|
42
|
+
stream: progressStream,
|
|
43
|
+
width: 32
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function debug(message, details) {
|
|
47
|
+
if (process.env.MATTERHORN_DEBUG !== "1") return;
|
|
48
|
+
const line = `matterhorn host debug: ${message} ${JSON.stringify(details)}`;
|
|
49
|
+
if (process.stderr?.write) process.stderr.write(`\n${line}\n`);
|
|
50
|
+
else logger.error ? logger.error(line) : logger.log(line);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const frontendTransferMetrics = createFrontendTransferMetrics({ store, debug });
|
|
54
|
+
|
|
55
|
+
function frontendForRequest(message, options = {}) {
|
|
56
|
+
if (options.refreshDynamic !== false) dynamicFrontend.refreshDynamicFrontends();
|
|
57
|
+
const frontends = Array.isArray(store.roomApp?.frontends) ? store.roomApp.frontends : [];
|
|
58
|
+
return frontendForMessage(frontends, message);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleFrontendManifestRequest(socket, peerId, message) {
|
|
62
|
+
if (!ensureValidProtocolAndRoom(store, socket, peerId, message, rejectRoomMismatch)) return;
|
|
63
|
+
const frontend = resolveFrontendForManifestResponse(store, frontendForRequest, message, dynamicFrontend);
|
|
64
|
+
if (!frontend) {
|
|
65
|
+
sendFrontendUnavailable(socket, peerId);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const cache = frontendCacheStatus(frontend, message);
|
|
69
|
+
logFrontendCacheStatus({ debug, store, source: "manifest", peerId, frontend, cache });
|
|
70
|
+
sendToClient(socket, peerId, {
|
|
71
|
+
type: "host/frontend-manifest",
|
|
72
|
+
protocol: PROTOCOL,
|
|
73
|
+
roomName: store.roomName,
|
|
74
|
+
frontend,
|
|
75
|
+
frontendCache: cache,
|
|
76
|
+
frontends: store.roomApp?.frontends || []
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function handleRoomInfoRequest(socket, peerId, message) {
|
|
81
|
+
if (!ensureValidProtocolAndRoom(store, socket, peerId, message, rejectRoomMismatch)) return;
|
|
82
|
+
dynamicFrontend.refreshDynamicFrontends();
|
|
83
|
+
if (!store.roomApp) {
|
|
84
|
+
sendFrontendUnavailable(socket, peerId, "Room app information is not available from this host.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const frontend = Array.isArray(store.roomApp.frontends) ? store.roomApp.frontends[0] : undefined;
|
|
88
|
+
const cache = frontend ? frontendCacheStatus(frontend, message) : undefined;
|
|
89
|
+
if (frontend && cache) logFrontendCacheStatus({ debug, store, source: "room.info", peerId, frontend, cache });
|
|
90
|
+
const response = createRoomInfoResponsePayload(store, cache, message, frontend);
|
|
91
|
+
sendToClient(socket, peerId, response);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handleFrontendChunkRequest = createFrontendChunkRequestHandler({
|
|
95
|
+
chunkProgress,
|
|
96
|
+
frontendBundle,
|
|
97
|
+
frontendForRequest,
|
|
98
|
+
frontendTransferMetrics,
|
|
99
|
+
rejectRoomMismatch,
|
|
100
|
+
sendFrontendUnavailable,
|
|
101
|
+
store
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
frontendBundleFile: (frontend) => findFrontendBundleFile(frontend, frontendBundle),
|
|
106
|
+
frontendForRequest,
|
|
107
|
+
handleFrontendChunkRequest,
|
|
108
|
+
handleFrontendManifestRequest,
|
|
109
|
+
handleRoomInfoRequest,
|
|
110
|
+
sendFrontendUnavailable
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
createFrontendRequestHandlers
|
|
116
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function frontendForMessage(frontends, message) {
|
|
2
|
+
if (message.frontendId && message.integrity) {
|
|
3
|
+
return frontends.find((frontend) => frontend.id === message.frontendId && frontend.integrity === message.integrity)
|
|
4
|
+
|| frontends.find((frontend) => frontend.integrity === message.integrity);
|
|
5
|
+
}
|
|
6
|
+
if (message.frontendId) return frontends.find((frontend) => frontend.id === message.frontendId);
|
|
7
|
+
if (message.integrity) return frontends.find((frontend) => frontend.integrity === message.integrity);
|
|
8
|
+
return frontends[0];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function frontendBundleFile(frontend, frontendBundle) {
|
|
12
|
+
if (!frontendBundle?.file) return undefined;
|
|
13
|
+
if (frontendBundle.id && frontendBundle.id !== frontend.id) return undefined;
|
|
14
|
+
if (!frontendBundle.dynamic && frontendBundle.integrity && frontendBundle.integrity !== frontend.integrity) return undefined;
|
|
15
|
+
return frontendBundle.file;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
frontendBundleFile,
|
|
20
|
+
frontendForMessage
|
|
21
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const { performance } = require("node:perf_hooks");
|
|
2
|
+
|
|
3
|
+
const FRONTEND_TRANSFER_METRIC_INTERVAL_MS = 2000;
|
|
4
|
+
|
|
5
|
+
function nowMs() {
|
|
6
|
+
return performance.now();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function roundMs(value) {
|
|
10
|
+
return Number(value.toFixed(2));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function rateMiBps(bytes, elapsedMs) {
|
|
14
|
+
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return 0;
|
|
15
|
+
return Number(((bytes / 1024 / 1024) / (elapsedMs / 1000)).toFixed(2));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function averageMs(total, count) {
|
|
19
|
+
if (!count) return 0;
|
|
20
|
+
return Number((total / count).toFixed(2));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function percentServed(bytes, total) {
|
|
24
|
+
if (!Number.isFinite(total) || total <= 0) return 100;
|
|
25
|
+
return Number(((Math.min(bytes, total) / total) * 100).toFixed(1));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createFrontendTransferMetrics({ store, debug }) {
|
|
29
|
+
const frontendTransfers = new Map();
|
|
30
|
+
|
|
31
|
+
function transferKey(peerId, frontend, message) {
|
|
32
|
+
return [peerId, frontend.id, frontend.integrity, message.path || ""].join("\0");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function record(details) {
|
|
36
|
+
const key = transferKey(details.peerId, details.frontend, details.message);
|
|
37
|
+
const now = nowMs();
|
|
38
|
+
let transfer = frontendTransfers.get(key);
|
|
39
|
+
if (!transfer) {
|
|
40
|
+
transfer = {
|
|
41
|
+
startedAt: now,
|
|
42
|
+
lastLogAt: now,
|
|
43
|
+
responses: 0,
|
|
44
|
+
uniqueBytes: 0,
|
|
45
|
+
completedBytes: 0,
|
|
46
|
+
seenOffsets: new Set(),
|
|
47
|
+
lookupMs: 0,
|
|
48
|
+
statMs: 0,
|
|
49
|
+
readMs: 0,
|
|
50
|
+
encodeMs: 0,
|
|
51
|
+
sendMs: 0,
|
|
52
|
+
handlerMs: 0
|
|
53
|
+
};
|
|
54
|
+
frontendTransfers.set(key, transfer);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
transfer.responses += 1;
|
|
58
|
+
if (!transfer.seenOffsets.has(details.offset)) {
|
|
59
|
+
transfer.seenOffsets.add(details.offset);
|
|
60
|
+
transfer.uniqueBytes += details.byteLength;
|
|
61
|
+
}
|
|
62
|
+
transfer.completedBytes = Math.max(transfer.completedBytes, details.offset + details.byteLength);
|
|
63
|
+
transfer.lookupMs += details.lookupMs;
|
|
64
|
+
transfer.statMs += details.statMs;
|
|
65
|
+
transfer.readMs += details.readMs;
|
|
66
|
+
transfer.encodeMs += details.encodeMs;
|
|
67
|
+
transfer.sendMs += details.sendMs;
|
|
68
|
+
transfer.handlerMs += details.handlerMs;
|
|
69
|
+
|
|
70
|
+
const elapsedMs = Math.max(1, now - transfer.startedAt);
|
|
71
|
+
const complete = transfer.completedBytes >= details.totalBytes;
|
|
72
|
+
const shouldLog = complete || now - transfer.lastLogAt >= FRONTEND_TRANSFER_METRIC_INTERVAL_MS;
|
|
73
|
+
if (!shouldLog) return { completed: transfer.completedBytes, complete };
|
|
74
|
+
|
|
75
|
+
transfer.lastLogAt = now;
|
|
76
|
+
debug("frontend transfer metrics", {
|
|
77
|
+
peerId: details.peerId,
|
|
78
|
+
roomName: store.roomName,
|
|
79
|
+
frontendId: details.frontend.id,
|
|
80
|
+
path: details.message.path,
|
|
81
|
+
totalBytes: details.totalBytes,
|
|
82
|
+
servedBytes: transfer.uniqueBytes,
|
|
83
|
+
remainingBytes: Math.max(0, details.totalBytes - transfer.completedBytes),
|
|
84
|
+
completedBytes: transfer.completedBytes,
|
|
85
|
+
percentServed: percentServed(transfer.uniqueBytes, details.totalBytes),
|
|
86
|
+
chunks: transfer.seenOffsets.size,
|
|
87
|
+
responses: transfer.responses,
|
|
88
|
+
retries: transfer.responses - transfer.seenOffsets.size,
|
|
89
|
+
avgChunkBytes: Math.round(transfer.uniqueBytes / transfer.seenOffsets.size),
|
|
90
|
+
elapsedMs: roundMs(elapsedMs),
|
|
91
|
+
throughputMiBps: rateMiBps(transfer.uniqueBytes, elapsedMs),
|
|
92
|
+
lastOffset: details.offset,
|
|
93
|
+
lastChunkBytes: details.byteLength,
|
|
94
|
+
lastHandlerMs: details.handlerMs,
|
|
95
|
+
avgHandlerMs: averageMs(transfer.handlerMs, transfer.responses),
|
|
96
|
+
avgReadMs: averageMs(transfer.readMs, transfer.responses),
|
|
97
|
+
avgEncodeMs: averageMs(transfer.encodeMs, transfer.responses),
|
|
98
|
+
avgSendEnqueueMs: averageMs(transfer.sendMs, transfer.responses),
|
|
99
|
+
complete
|
|
100
|
+
});
|
|
101
|
+
if (complete) frontendTransfers.delete(key);
|
|
102
|
+
return { completed: transfer.completedBytes, complete };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { record };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
createFrontendTransferMetrics,
|
|
110
|
+
nowMs
|
|
111
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { PROTOCOL } = require("@mh-gg/host-config");
|
|
2
|
+
const { sendToClient } = require("@mh-gg/host-ipc");
|
|
3
|
+
|
|
4
|
+
function sendFrontendUnavailable(socket, peerId, message = "The room frontend bundle is not available from this host.") {
|
|
5
|
+
sendToClient(socket, peerId, {
|
|
6
|
+
type: "host/error",
|
|
7
|
+
protocol: PROTOCOL,
|
|
8
|
+
code: "frontend-unavailable",
|
|
9
|
+
message
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
sendFrontendUnavailable
|
|
15
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { validateClientMessage } = require("@mh-gg/relay");
|
|
2
|
+
const { createFrontendRequestHandlers } = require("./frontendRequests.cjs");
|
|
3
|
+
const { rejectInvalidMessage, rejectRoomMismatch } = require("./rejections.cjs");
|
|
4
|
+
|
|
5
|
+
function createHostClients(options) {
|
|
6
|
+
const {
|
|
7
|
+
store,
|
|
8
|
+
logger = console,
|
|
9
|
+
frontendBundle,
|
|
10
|
+
frontendProgress,
|
|
11
|
+
onStoreChange = () => {}
|
|
12
|
+
} = options;
|
|
13
|
+
const connectionRoles = new Map();
|
|
14
|
+
const pendingJoinConnections = new Map();
|
|
15
|
+
const frontend = createFrontendRequestHandlers({ store, frontendBundle, frontendProgress, logger, rejectRoomMismatch, onRoomAppChange: onStoreChange });
|
|
16
|
+
|
|
17
|
+
function debug(message, ...args) {
|
|
18
|
+
if (process.env.MATTERHORN_DEBUG === "1") logger.log(message, ...args);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function messageDebugSummary(peerId, message) {
|
|
22
|
+
const keys = message && typeof message === "object" && !Array.isArray(message) ? Object.keys(message).sort() : [];
|
|
23
|
+
return {
|
|
24
|
+
peerId,
|
|
25
|
+
type: message?.type,
|
|
26
|
+
roomName: message?.roomName,
|
|
27
|
+
clientId: message?.clientId,
|
|
28
|
+
keys
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleClientMessage(socket, peerId, message) {
|
|
33
|
+
const validation = validateClientMessage(message);
|
|
34
|
+
if (!validation.ok) {
|
|
35
|
+
debug("Invalid client message", validation.message, messageDebugSummary(peerId, message));
|
|
36
|
+
rejectInvalidMessage(socket, peerId, validation);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (message.type === "client/frontend-manifest") {
|
|
40
|
+
frontend.handleFrontendManifestRequest(socket, peerId, message);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (message.type === "client/frontend-chunk") {
|
|
44
|
+
frontend.handleFrontendChunkRequest(socket, peerId, message);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (message.type === "client/room-info") {
|
|
48
|
+
frontend.handleRoomInfoRequest(socket, peerId, message);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
debug("Unsupported host client message", messageDebugSummary(peerId, message));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function removeConnection(peerId) {
|
|
55
|
+
connectionRoles.delete(peerId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
connectionRoles,
|
|
60
|
+
handleClientMessage,
|
|
61
|
+
pendingJoinConnections,
|
|
62
|
+
removeConnection
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
createHostClients
|
|
68
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { PROTOCOL } = require("@mh-gg/host-config");
|
|
2
|
+
const { closeClient, sendToClient } = require("@mh-gg/host-ipc");
|
|
3
|
+
|
|
4
|
+
function rejectRoomMismatch(socket, peerId, closeConnection) {
|
|
5
|
+
sendToClient(socket, peerId, {
|
|
6
|
+
type: "host/error",
|
|
7
|
+
protocol: PROTOCOL,
|
|
8
|
+
code: "room-mismatch",
|
|
9
|
+
message: "Wrong room or protocol."
|
|
10
|
+
});
|
|
11
|
+
if (closeConnection) closeClient(socket, peerId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function rejectInvalidMessage(socket, peerId, validation) {
|
|
15
|
+
sendToClient(socket, peerId, {
|
|
16
|
+
type: "host/error",
|
|
17
|
+
protocol: PROTOCOL,
|
|
18
|
+
code: validation.code,
|
|
19
|
+
message: validation.message
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sendHostError(socket, peerId, code, message, extra = {}) {
|
|
24
|
+
sendToClient(socket, peerId, {
|
|
25
|
+
type: "host/error",
|
|
26
|
+
protocol: PROTOCOL,
|
|
27
|
+
code,
|
|
28
|
+
message,
|
|
29
|
+
...extra
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
rejectInvalidMessage,
|
|
35
|
+
rejectRoomMismatch,
|
|
36
|
+
sendHostError
|
|
37
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
function isIgnoredSourcePath(candidate, ignoredRoots) {
|
|
5
|
+
const base = path.basename(candidate);
|
|
6
|
+
if (base === "node_modules" || base === ".git") return true;
|
|
7
|
+
const resolved = path.resolve(candidate);
|
|
8
|
+
return ignoredRoots.some((ignoredRoot) => {
|
|
9
|
+
const relative = path.relative(ignoredRoot, resolved);
|
|
10
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function directSourceMtime(root, ignoredRoots) {
|
|
15
|
+
const stat = fs.existsSync(root) ? fs.statSync(root) : undefined;
|
|
16
|
+
if (!stat?.isFile()) return undefined;
|
|
17
|
+
return isIgnoredSourcePath(root, ignoredRoots) ? 0 : stat.mtimeMs;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function entrySourceMtime(entryPath, entry, pending, ignoredRoots) {
|
|
21
|
+
if (isIgnoredSourcePath(entryPath, ignoredRoots)) return 0;
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
pending.push(entryPath);
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
if (!entry.isFile()) return 0;
|
|
27
|
+
try {
|
|
28
|
+
return fs.statSync(entryPath).mtimeMs;
|
|
29
|
+
} catch {
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listDirectoryEntries(directory) {
|
|
35
|
+
try {
|
|
36
|
+
return fs.readdirSync(directory, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function collectMtimeFromEntries(current, entries, pending, ignoredRoots) {
|
|
43
|
+
let latest = 0;
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const entryPath = path.join(current, entry.name);
|
|
46
|
+
latest = Math.max(latest, entrySourceMtime(entryPath, entry, pending, ignoredRoots));
|
|
47
|
+
}
|
|
48
|
+
return latest;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function latestSourceMtime(root, ignoredRoots) {
|
|
52
|
+
const directMtime = directSourceMtime(root, ignoredRoots);
|
|
53
|
+
if (directMtime !== undefined) return directMtime;
|
|
54
|
+
let latest = 0;
|
|
55
|
+
const pending = [root];
|
|
56
|
+
while (pending.length > 0) {
|
|
57
|
+
const current = pending.pop();
|
|
58
|
+
if (!current || isIgnoredSourcePath(current, ignoredRoots)) continue;
|
|
59
|
+
latest = Math.max(latest, collectMtimeFromEntries(current, listDirectoryEntries(current), pending, ignoredRoots));
|
|
60
|
+
}
|
|
61
|
+
return latest;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function latestSourceRootsMtime(roots, ignoredRoots) {
|
|
65
|
+
return roots.reduce((latest, root) => Math.max(latest, latestSourceMtime(root, ignoredRoots)), 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { latestSourceRootsMtime };
|