@mh-gg/cli 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/README.md +5 -0
- package/bin/matterhorn.cjs +57 -0
- package/package.json +49 -0
- package/runtime/bin/appFrontend/artifacts.cjs +25 -0
- package/runtime/bin/appFrontend/buildServers.cjs +176 -0
- package/runtime/bin/appFrontend/commandEnv.cjs +74 -0
- package/runtime/bin/appFrontend/commandPolicy.cjs +23 -0
- package/runtime/bin/appFrontend/devServers.cjs +150 -0
- package/runtime/bin/appFrontend/httpServers.cjs +221 -0
- package/runtime/bin/appFrontend/paths.cjs +103 -0
- package/runtime/bin/appFrontend/ports.cjs +36 -0
- package/runtime/bin/appFrontend/processes.cjs +127 -0
- package/runtime/bin/appFrontend.cjs +45 -0
- package/runtime/bin/appHostCommand.cjs +381 -0
- package/runtime/bin/matterhorn.cjs +501 -0
- package/runtime/bin/matterhornAppLoader.cjs +588 -0
- package/runtime/bin/matterhornApps.cjs +223 -0
- package/runtime/bin/matterhornDeploy.cjs +108 -0
- package/runtime/bin/matterhornEmitAppBundle.cjs +20 -0
- package/runtime/bin/matterhornInstall.cjs +609 -0
- package/runtime/host/callAuth.cjs +76 -0
- package/runtime/host/host.cjs +103 -0
- package/runtime/host/hostAnnouncement.cjs +70 -0
- package/runtime/host/hostClients/constants.cjs +7 -0
- package/runtime/host/hostClients/frontendBundleRefresh.cjs +158 -0
- package/runtime/host/hostClients/frontendRequests.cjs +166 -0
- package/runtime/host/hostClients/index.cjs +68 -0
- package/runtime/host/hostClients/rejections.cjs +37 -0
- package/runtime/host/hostSession.cjs +160 -0
- package/runtime/host/inlineProgressBar.cjs +128 -0
- package/runtime/host/localPeerServer.cjs +114 -0
- package/runtime/host/localRelayClient.cjs +151 -0
- package/runtime/host/matterhornrc.cjs +75 -0
- package/runtime/host/memberRootRegistry.cjs +132 -0
- package/runtime/host/nodePeer.cjs +127 -0
- package/runtime/host/nodePeerRacePatch.cjs +106 -0
- package/runtime/host/peerJsConfig.cjs +26 -0
- package/runtime/host/pushEgress.cjs +48 -0
- package/runtime/host/pushStorage.cjs +233 -0
- package/runtime/host/relay/config.cjs +179 -0
- package/runtime/host/relay/connectionCleanup.cjs +34 -0
- package/runtime/host/relay/connectionDispatcher.cjs +140 -0
- package/runtime/host/relay/matterhornOperationEvents.cjs +100 -0
- package/runtime/host/relay/matterhornRuntimeEventBridge.cjs +182 -0
- package/runtime/host/relay/nostrRelay.cjs +30 -0
- package/runtime/host/relay/peerStartup.cjs +81 -0
- package/runtime/host/relay.cjs +653 -0
- package/runtime/host/relayClientRouting.cjs +1054 -0
- package/runtime/host/relayConfig.cjs +156 -0
- package/runtime/host/relayHostAuth.cjs +39 -0
- package/runtime/host/relayHostMessages.cjs +367 -0
- package/runtime/host/relayHttp.cjs +48 -0
- package/runtime/host/relayIdentity.cjs +496 -0
- package/runtime/host/relayIncomingGate.cjs +153 -0
- package/runtime/host/relayMeshEnvelopes.cjs +522 -0
- package/runtime/host/relayPeerLifecycle.cjs +96 -0
- package/runtime/host/relayPeerSignals.cjs +175 -0
- package/runtime/host/relayRoomRuntimePersistence.cjs +129 -0
- package/runtime/host/relayStatus.cjs +160 -0
- package/runtime/host/sfuRelay.cjs +553 -0
- package/runtime/host/sqliteRelayStorage.cjs +352 -0
- package/runtime/host/wireValidation/client.cjs +213 -0
- package/runtime/host/wireValidation/host.cjs +33 -0
- package/runtime/host/wireValidation/index.cjs +13 -0
- package/runtime/host/wireValidation/peerSignal.cjs +35 -0
- package/runtime/host/wireValidation/presenceEvent.cjs +49 -0
- package/runtime/host/wireValidation/push.cjs +49 -0
- package/runtime/host/wireValidation/relay.cjs +131 -0
- package/runtime/host/wireValidation/shared.cjs +49 -0
- package/runtime/scripts/ensureWorkspaceSdkBuild.cjs +148 -0
- package/runtime/scripts/killChildTree.cjs +18 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const WebSocket = require("ws");
|
|
5
|
+
const { ensureRoomSecurity } = require("@mh-gg/room-security");
|
|
6
|
+
const { parseArgs } = require("@mh-gg/host-config");
|
|
7
|
+
const { loadStore, saveStore } = require("@mh-gg/host-store");
|
|
8
|
+
const { normalizeAppUrl } = require("@mh-gg/room-link");
|
|
9
|
+
const { applymatterhornrcDefaults } = require("./matterhornrc.cjs");
|
|
10
|
+
const { ensureLocalRelay, relayIpcUrl } = require("./localRelayClient.cjs");
|
|
11
|
+
const { createHostSession } = require("./hostSession.cjs");
|
|
12
|
+
|
|
13
|
+
function roomAppFromEnv(env = process.env) {
|
|
14
|
+
if (!env.MATTERHORN_ROOM_APP) return undefined;
|
|
15
|
+
try {
|
|
16
|
+
const value = JSON.parse(env.MATTERHORN_ROOM_APP);
|
|
17
|
+
if (!value || typeof value !== "object") return undefined;
|
|
18
|
+
return value;
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function frontendBundleFromEnv(env = process.env) {
|
|
25
|
+
if (!env.MATTERHORN_FRONTEND_BUNDLE_FILE) return undefined;
|
|
26
|
+
let rebuild;
|
|
27
|
+
if (env.MATTERHORN_FRONTEND_BUNDLE_REBUILD) {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(env.MATTERHORN_FRONTEND_BUNDLE_REBUILD);
|
|
30
|
+
if (parsed && typeof parsed === "object" && typeof parsed.command === "string") rebuild = parsed;
|
|
31
|
+
} catch {
|
|
32
|
+
rebuild = undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const sourceRoots = env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_ROOTS
|
|
36
|
+
? env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_ROOTS.split(path.delimiter).filter(Boolean).map((item) => path.resolve(item))
|
|
37
|
+
: undefined;
|
|
38
|
+
return {
|
|
39
|
+
file: path.resolve(env.MATTERHORN_FRONTEND_BUNDLE_FILE),
|
|
40
|
+
id: env.MATTERHORN_FRONTEND_BUNDLE_ID || undefined,
|
|
41
|
+
integrity: env.MATTERHORN_FRONTEND_BUNDLE_INTEGRITY || undefined,
|
|
42
|
+
dynamic: env.MATTERHORN_FRONTEND_BUNDLE_DYNAMIC === "1",
|
|
43
|
+
sourceRoot: env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_ROOT ? path.resolve(env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_ROOT) : undefined,
|
|
44
|
+
...(sourceRoots ? { sourceRoots } : {}),
|
|
45
|
+
rebuild
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appRefFromEnv(env = process.env) {
|
|
50
|
+
if (!env.MATTERHORN_APP_REF) return undefined;
|
|
51
|
+
return {
|
|
52
|
+
appRef: env.MATTERHORN_APP_REF,
|
|
53
|
+
appCwd: env.MATTERHORN_APP_CWD || process.cwd()
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function main() {
|
|
58
|
+
applymatterhornrcDefaults();
|
|
59
|
+
const args = parseArgs(process.argv.slice(2), {
|
|
60
|
+
defaultDataDir: path.join(__dirname, "data")
|
|
61
|
+
});
|
|
62
|
+
const store = loadStore(args);
|
|
63
|
+
const appUrl = normalizeAppUrl(args.appUrl);
|
|
64
|
+
const localRelay = await ensureLocalRelay({
|
|
65
|
+
relayPeers: args.relayPeers,
|
|
66
|
+
iceServers: args.iceServers,
|
|
67
|
+
localPeerjs: args.localPeerjs
|
|
68
|
+
});
|
|
69
|
+
const roomPeerId = localRelay.info.relayPeerId || localRelay.config.relayPeerId;
|
|
70
|
+
store.roomPeerId = roomPeerId;
|
|
71
|
+
store.relayAddress = localRelay.info.relayAddress || localRelay.config.relayAddress;
|
|
72
|
+
const roomApp = roomAppFromEnv();
|
|
73
|
+
if (roomApp) store.roomApp = roomApp;
|
|
74
|
+
ensureRoomSecurity(store);
|
|
75
|
+
saveStore(store);
|
|
76
|
+
|
|
77
|
+
const hostSession = createHostSession({
|
|
78
|
+
store,
|
|
79
|
+
args,
|
|
80
|
+
appUrl,
|
|
81
|
+
...appRefFromEnv(),
|
|
82
|
+
frontendBundle: frontendBundleFromEnv(),
|
|
83
|
+
localRelay,
|
|
84
|
+
roomPeerId,
|
|
85
|
+
reconnectRelay: true
|
|
86
|
+
});
|
|
87
|
+
const relaySocket = new WebSocket(relayIpcUrl(localRelay.config));
|
|
88
|
+
hostSession.attachRelaySocket(relaySocket);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (require.main === module) {
|
|
92
|
+
main().catch((error) => {
|
|
93
|
+
console.error(error);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
frontendBundleFromEnv,
|
|
100
|
+
appRefFromEnv,
|
|
101
|
+
main,
|
|
102
|
+
roomAppFromEnv
|
|
103
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const qrcode = require("qrcode-terminal");
|
|
2
|
+
const { activePublicInvite } = require("@mh-gg/room-security");
|
|
3
|
+
const { stateFile } = require("@mh-gg/host-store");
|
|
4
|
+
const { buildInviteUrl } = require("@mh-gg/room-link");
|
|
5
|
+
const { peerJsSignalingUrl } = require("./peerJsConfig.cjs");
|
|
6
|
+
|
|
7
|
+
function buildInvite(store, appUrl, roomPeerId, invite, adminToken) {
|
|
8
|
+
return buildInviteUrl(appUrl, store.roomName, roomPeerId, invite.secret, store.relayAddress, invite.id, adminToken);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function announceGuestInvite(options) {
|
|
12
|
+
const {
|
|
13
|
+
store,
|
|
14
|
+
appUrl,
|
|
15
|
+
roomPeerId,
|
|
16
|
+
logger = console,
|
|
17
|
+
qr = qrcode
|
|
18
|
+
} = options;
|
|
19
|
+
const invite = activePublicInvite(store);
|
|
20
|
+
if (!invite) {
|
|
21
|
+
logger.log("Guest Invite: no active public invite\n");
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const shareUrl = buildInvite(store, appUrl, roomPeerId, invite);
|
|
26
|
+
logger.log(`Guest Invite: ${shareUrl}\n`);
|
|
27
|
+
qr.generate(shareUrl, { small: true });
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function announceHost(options) {
|
|
32
|
+
const {
|
|
33
|
+
store,
|
|
34
|
+
appUrl,
|
|
35
|
+
localRelay,
|
|
36
|
+
roomPeerId,
|
|
37
|
+
logger = console,
|
|
38
|
+
qr = qrcode
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
logger.log("\nMatterhorn host is live through the local relay.");
|
|
42
|
+
logger.log(`Room: ${store.roomName}`);
|
|
43
|
+
logger.log(`Room peer: ${roomPeerId}`);
|
|
44
|
+
logger.log(`Relay mesh: ${store.relayAddress}`);
|
|
45
|
+
logger.log(`Relay IPC: ws://${localRelay.config.ipcHost}:${localRelay.config.ipcPort}/room${localRelay.spawned ? " (started)" : " (existing)"}`);
|
|
46
|
+
logger.log(`Signaling: ${peerJsSignalingUrl()}`);
|
|
47
|
+
logger.log(`Launcher: ${appUrl}`);
|
|
48
|
+
if (store.roomApp?.appPack?.id) logger.log(`Room app: ${store.roomApp.appPack.id}@${store.roomApp.appPack.version || "unknown"}`);
|
|
49
|
+
const frontend = Array.isArray(store.roomApp?.frontends) ? store.roomApp.frontends[0] : undefined;
|
|
50
|
+
if (frontend?.id) logger.log(`Frontend: ${frontend.id}@${frontend.version || "unknown"} via ${frontend.delivery || "unknown"}`);
|
|
51
|
+
logger.log(`Data file: ${stateFile(store.dataDir, store.roomName)}`);
|
|
52
|
+
const invite = activePublicInvite(store);
|
|
53
|
+
let guestInviteAnnounced = false;
|
|
54
|
+
if (invite) {
|
|
55
|
+
if (store.adminBootstrapToken) {
|
|
56
|
+
const adminUrl = buildInvite(store, appUrl, roomPeerId, invite, store.adminBootstrapToken);
|
|
57
|
+
logger.log(`JOIN AS ADMIN: ${adminUrl}\n`);
|
|
58
|
+
}
|
|
59
|
+
guestInviteAnnounced = announceGuestInvite(options);
|
|
60
|
+
} else {
|
|
61
|
+
logger.log("Guest Invite: no active public invite\n");
|
|
62
|
+
}
|
|
63
|
+
logger.log("\nKeep this process running while guests are connected. Press Ctrl+C to stop.\n");
|
|
64
|
+
return { guestInviteAnnounced };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
announceGuestInvite,
|
|
69
|
+
announceHost
|
|
70
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const { createHash } = require("node:crypto");
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { commandInvocation, replaceTokens } = require("../../bin/appFrontend/processes.cjs");
|
|
6
|
+
const { buildMatterhornCommandEnv } = require("../../bin/appFrontend/commandEnv.cjs");
|
|
7
|
+
const { assertAppCommandAllowed, commandEnvTrustForPolicy } = require("../../bin/appFrontend/commandPolicy.cjs");
|
|
8
|
+
|
|
9
|
+
function fileIntegrity(file) {
|
|
10
|
+
return `sha256-${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isIgnoredSourcePath(candidate, ignoredRoots) {
|
|
14
|
+
const base = path.basename(candidate);
|
|
15
|
+
if (base === "node_modules" || base === ".git") return true;
|
|
16
|
+
const resolved = path.resolve(candidate);
|
|
17
|
+
return ignoredRoots.some((ignoredRoot) => {
|
|
18
|
+
const relative = path.relative(ignoredRoot, resolved);
|
|
19
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function latestSourceMtime(root, ignoredRoots) {
|
|
24
|
+
const stat = fs.existsSync(root) ? fs.statSync(root) : undefined;
|
|
25
|
+
if (stat?.isFile()) return isIgnoredSourcePath(root, ignoredRoots) ? 0 : stat.mtimeMs;
|
|
26
|
+
let latest = 0;
|
|
27
|
+
const pending = [root];
|
|
28
|
+
while (pending.length > 0) {
|
|
29
|
+
const current = pending.pop();
|
|
30
|
+
if (!current || isIgnoredSourcePath(current, ignoredRoots)) continue;
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const entryPath = path.join(current, entry.name);
|
|
39
|
+
if (isIgnoredSourcePath(entryPath, ignoredRoots)) continue;
|
|
40
|
+
if (entry.isDirectory()) {
|
|
41
|
+
pending.push(entryPath);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!entry.isFile()) continue;
|
|
45
|
+
try {
|
|
46
|
+
latest = Math.max(latest, fs.statSync(entryPath).mtimeMs);
|
|
47
|
+
} catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return latest;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function latestSourceRootsMtime(roots, ignoredRoots) {
|
|
56
|
+
return roots.reduce((latest, root) => Math.max(latest, latestSourceMtime(root, ignoredRoots)), 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rebuildEnv(rebuild, options, policy) {
|
|
60
|
+
const env = Object.fromEntries(
|
|
61
|
+
Object.entries(rebuild.env || {}).map(([key, value]) => [key, replaceTokens(value, options)])
|
|
62
|
+
);
|
|
63
|
+
return buildMatterhornCommandEnv({
|
|
64
|
+
trust: commandEnvTrustForPolicy(policy || rebuild.trust || "trusted-local-dev"),
|
|
65
|
+
commandEnv: env,
|
|
66
|
+
port: options.port,
|
|
67
|
+
bundlePort: options.bundlePort,
|
|
68
|
+
mountPath: options.mountPath,
|
|
69
|
+
unsafeInheritEnv: rebuild.unsafeInheritEnv,
|
|
70
|
+
allowSensitiveCommandEnv: rebuild.allowSensitiveCommandEnv
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runRebuildCommand(command, options, policy) {
|
|
75
|
+
if (!command?.command) return true;
|
|
76
|
+
const args = Array.isArray(command.args) ? command.args.map((arg) => replaceTokens(arg, options)) : [];
|
|
77
|
+
const invocation = commandInvocation(command.command, args);
|
|
78
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
79
|
+
cwd: command.cwd || process.cwd(),
|
|
80
|
+
env: rebuildEnv(command, options, policy),
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
83
|
+
windowsHide: true
|
|
84
|
+
});
|
|
85
|
+
return result.status === 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runDynamicBundleRebuild(frontendBundle) {
|
|
89
|
+
const rebuild = frontendBundle?.rebuild;
|
|
90
|
+
if (!rebuild?.command) return false;
|
|
91
|
+
const policy = assertAppCommandAllowed({ commandExecution: rebuild.trust || "trusted-local-dev" }, "matterhorn dynamic bundle rebuild");
|
|
92
|
+
const options = {
|
|
93
|
+
port: rebuild.port || 0,
|
|
94
|
+
bundlePort: rebuild.bundlePort || rebuild.port || 0,
|
|
95
|
+
mountPath: rebuild.mountPath || "/"
|
|
96
|
+
};
|
|
97
|
+
for (const command of rebuild.before || []) {
|
|
98
|
+
if (!runRebuildCommand(command, options, policy)) return false;
|
|
99
|
+
}
|
|
100
|
+
return runRebuildCommand(rebuild, options, policy);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function rebuildDynamicBundleIfSourceChanged(frontendBundle) {
|
|
104
|
+
if (!frontendBundle?.dynamic || !frontendBundle.sourceRoot || !frontendBundle.file) return;
|
|
105
|
+
const roots = (frontendBundle.sourceRoots || [frontendBundle.sourceRoot]).filter(Boolean).map((root) => path.resolve(root));
|
|
106
|
+
if (roots.length === 0 || roots.every((root) => !fs.existsSync(root))) return;
|
|
107
|
+
const bundleFile = path.resolve(frontendBundle.file);
|
|
108
|
+
const bundleDir = path.dirname(bundleFile);
|
|
109
|
+
const bundleMtime = fs.existsSync(bundleFile) ? fs.statSync(bundleFile).mtimeMs : 0;
|
|
110
|
+
if (latestSourceRootsMtime(roots, [bundleDir]) <= bundleMtime) return;
|
|
111
|
+
runDynamicBundleRebuild(frontendBundle);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function createDynamicFrontendRefresher({ store, frontendBundle, onRoomAppChange = () => {} }) {
|
|
115
|
+
function refreshDynamicFrontend(frontend) {
|
|
116
|
+
if (!frontendBundle?.dynamic || !frontendBundle.file || !frontend) return frontend;
|
|
117
|
+
if (frontendBundle.id && frontendBundle.id !== frontend.id) return frontend;
|
|
118
|
+
rebuildDynamicBundleIfSourceChanged(frontendBundle);
|
|
119
|
+
if (!fs.existsSync(frontendBundle.file)) return frontend;
|
|
120
|
+
let stat;
|
|
121
|
+
let integrity;
|
|
122
|
+
try {
|
|
123
|
+
stat = fs.statSync(frontendBundle.file);
|
|
124
|
+
if (!stat.isFile()) return frontend;
|
|
125
|
+
integrity = fileIntegrity(frontendBundle.file);
|
|
126
|
+
} catch {
|
|
127
|
+
return frontend;
|
|
128
|
+
}
|
|
129
|
+
if (frontend.integrity === integrity && frontend.byteLength === stat.size) return frontend;
|
|
130
|
+
|
|
131
|
+
const frontends = Array.isArray(store.roomApp?.frontends) ? store.roomApp.frontends : [];
|
|
132
|
+
const index = frontends.findIndex((candidate) => candidate.id === frontend.id);
|
|
133
|
+
if (index < 0) return frontend;
|
|
134
|
+
const nextFrontend = { ...frontend, integrity, byteLength: stat.size };
|
|
135
|
+
store.roomApp = {
|
|
136
|
+
...store.roomApp,
|
|
137
|
+
frontends: frontends.map((candidate, candidateIndex) => candidateIndex === index ? nextFrontend : candidate)
|
|
138
|
+
};
|
|
139
|
+
onRoomAppChange(store.roomApp);
|
|
140
|
+
return nextFrontend;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function refreshDynamicFrontends() {
|
|
144
|
+
const frontends = Array.isArray(store.roomApp?.frontends) ? store.roomApp.frontends : [];
|
|
145
|
+
for (const frontend of frontends) refreshDynamicFrontend(frontend);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
refreshDynamicFrontend,
|
|
150
|
+
refreshDynamicFrontends
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
createDynamicFrontendRefresher,
|
|
156
|
+
rebuildEnv,
|
|
157
|
+
runDynamicBundleRebuild
|
|
158
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const { PROTOCOL } = require("@mh-gg/host-config");
|
|
3
|
+
const { sendToClient } = require("@mh-gg/host-ipc");
|
|
4
|
+
const { DEFAULT_FRONTEND_CHUNK_BYTES } = require("./constants.cjs");
|
|
5
|
+
const { createDynamicFrontendRefresher } = require("./frontendBundleRefresh.cjs");
|
|
6
|
+
const { createInlineProgressLoader } = require("../inlineProgressBar.cjs");
|
|
7
|
+
|
|
8
|
+
function createFrontendRequestHandlers({ store, frontendBundle, rejectRoomMismatch, onRoomAppChange = () => {}, frontendProgress, progressStream }) {
|
|
9
|
+
const dynamicFrontend = createDynamicFrontendRefresher({ store, frontendBundle, onRoomAppChange });
|
|
10
|
+
const chunkProgress = frontendProgress || createInlineProgressLoader("Loading frontend chunks...", {
|
|
11
|
+
stream: progressStream,
|
|
12
|
+
width: 32
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function cachedFrontendProof(message) {
|
|
16
|
+
const proof = message?.cachedFrontend;
|
|
17
|
+
if (!proof || typeof proof !== "object" || Array.isArray(proof)) return undefined;
|
|
18
|
+
if (typeof proof.id !== "string" || !proof.id) return undefined;
|
|
19
|
+
if (typeof proof.integrity !== "string" || !proof.integrity) return undefined;
|
|
20
|
+
const result = { id: proof.id, integrity: proof.integrity };
|
|
21
|
+
if (Number.isInteger(proof.byteLength) && proof.byteLength >= 0) result.byteLength = proof.byteLength;
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function frontendCacheStatus(frontend, message) {
|
|
26
|
+
const proof = cachedFrontendProof(message);
|
|
27
|
+
const sameFrontend = proof?.id === frontend.id && proof.integrity === frontend.integrity;
|
|
28
|
+
const sameLength = proof?.byteLength === undefined || frontend.byteLength === undefined || proof.byteLength === frontend.byteLength;
|
|
29
|
+
return {
|
|
30
|
+
frontendId: frontend.id,
|
|
31
|
+
integrity: frontend.integrity,
|
|
32
|
+
byteLength: frontend.byteLength,
|
|
33
|
+
clientHasCurrentBundle: Boolean(sameFrontend && sameLength)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function frontendForRequest(message) {
|
|
38
|
+
dynamicFrontend.refreshDynamicFrontends();
|
|
39
|
+
const frontends = Array.isArray(store.roomApp?.frontends) ? store.roomApp.frontends : [];
|
|
40
|
+
if (message.frontendId) return frontends.find((frontend) => frontend.id === message.frontendId);
|
|
41
|
+
if (message.integrity) return frontends.find((frontend) => frontend.integrity === message.integrity);
|
|
42
|
+
return frontends[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function frontendBundleFile(frontend) {
|
|
46
|
+
if (!frontendBundle?.file) return undefined;
|
|
47
|
+
if (frontendBundle.id && frontendBundle.id !== frontend.id) return undefined;
|
|
48
|
+
if (!frontendBundle.dynamic && frontendBundle.integrity && frontendBundle.integrity !== frontend.integrity) return undefined;
|
|
49
|
+
return frontendBundle.file;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sendFrontendUnavailable(socket, peerId, message = "The room frontend bundle is not available from this host.") {
|
|
53
|
+
sendToClient(socket, peerId, {
|
|
54
|
+
type: "host/error",
|
|
55
|
+
protocol: PROTOCOL,
|
|
56
|
+
code: "frontend-unavailable",
|
|
57
|
+
message
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleFrontendManifestRequest(socket, peerId, message) {
|
|
62
|
+
if (message.protocol !== PROTOCOL || message.roomName !== store.roomName) {
|
|
63
|
+
rejectRoomMismatch(socket, peerId, false);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const frontend = frontendForRequest(message);
|
|
67
|
+
if (!frontend) {
|
|
68
|
+
sendFrontendUnavailable(socket, peerId);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
sendToClient(socket, peerId, {
|
|
72
|
+
type: "host/frontend-manifest",
|
|
73
|
+
protocol: PROTOCOL,
|
|
74
|
+
roomName: store.roomName,
|
|
75
|
+
frontend,
|
|
76
|
+
frontendCache: frontendCacheStatus(frontend, message),
|
|
77
|
+
frontends: store.roomApp?.frontends || []
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function handleRoomInfoRequest(socket, peerId, message) {
|
|
82
|
+
if (message.protocol !== PROTOCOL || message.roomName !== store.roomName) {
|
|
83
|
+
rejectRoomMismatch(socket, peerId, false);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
dynamicFrontend.refreshDynamicFrontends();
|
|
87
|
+
if (!store.roomApp) {
|
|
88
|
+
sendFrontendUnavailable(socket, peerId, "Room app information is not available from this host.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const frontend = Array.isArray(store.roomApp.frontends) ? store.roomApp.frontends[0] : undefined;
|
|
92
|
+
const cache = frontend ? frontendCacheStatus(frontend, message) : undefined;
|
|
93
|
+
const response = {
|
|
94
|
+
type: "room.info",
|
|
95
|
+
protocol: PROTOCOL,
|
|
96
|
+
roomName: store.roomName,
|
|
97
|
+
roomApp: store.roomApp
|
|
98
|
+
};
|
|
99
|
+
if (message.clientId) response.clientId = message.clientId;
|
|
100
|
+
if (cache) response.frontendCache = cache;
|
|
101
|
+
sendToClient(socket, peerId, response);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleFrontendChunkRequest(socket, peerId, message) {
|
|
105
|
+
if (message.protocol !== PROTOCOL || message.roomName !== store.roomName) {
|
|
106
|
+
rejectRoomMismatch(socket, peerId, false);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const frontend = frontendForRequest(message);
|
|
110
|
+
if (!frontend) {
|
|
111
|
+
sendFrontendUnavailable(socket, peerId);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const file = frontendBundleFile(frontend);
|
|
115
|
+
if (!file || !fs.existsSync(file)) {
|
|
116
|
+
sendFrontendUnavailable(socket, peerId);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const stat = fs.statSync(file);
|
|
120
|
+
const offset = message.offset || 0;
|
|
121
|
+
const requestedLength = message.length || frontend.chunkSize || DEFAULT_FRONTEND_CHUNK_BYTES;
|
|
122
|
+
const maxLength = Math.max(1, Math.min(requestedLength, frontend.chunkSize || DEFAULT_FRONTEND_CHUNK_BYTES, DEFAULT_FRONTEND_CHUNK_BYTES));
|
|
123
|
+
const byteLength = Math.max(0, Math.min(maxLength, stat.size - offset));
|
|
124
|
+
const buffer = byteLength > 0 ? Buffer.allocUnsafe(byteLength) : Buffer.alloc(0);
|
|
125
|
+
if (byteLength > 0) {
|
|
126
|
+
const fd = fs.openSync(file, "r");
|
|
127
|
+
try {
|
|
128
|
+
fs.readSync(fd, buffer, 0, byteLength, offset);
|
|
129
|
+
} finally {
|
|
130
|
+
fs.closeSync(fd);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
sendToClient(socket, peerId, {
|
|
134
|
+
type: "host/frontend-chunk",
|
|
135
|
+
protocol: PROTOCOL,
|
|
136
|
+
roomName: store.roomName,
|
|
137
|
+
frontendId: frontend.id,
|
|
138
|
+
integrity: frontend.integrity,
|
|
139
|
+
offset,
|
|
140
|
+
byteLength,
|
|
141
|
+
totalBytes: stat.size,
|
|
142
|
+
encoding: "base64",
|
|
143
|
+
data: buffer.toString("base64"),
|
|
144
|
+
done: offset + byteLength >= stat.size
|
|
145
|
+
});
|
|
146
|
+
chunkProgress.update({
|
|
147
|
+
completed: offset + byteLength,
|
|
148
|
+
total: stat.size,
|
|
149
|
+
currentItem: frontend.name || frontend.id
|
|
150
|
+
});
|
|
151
|
+
if (offset + byteLength >= stat.size) chunkProgress.stop();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
frontendBundleFile,
|
|
156
|
+
frontendForRequest,
|
|
157
|
+
handleFrontendChunkRequest,
|
|
158
|
+
handleFrontendManifestRequest,
|
|
159
|
+
handleRoomInfoRequest,
|
|
160
|
+
sendFrontendUnavailable
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
createFrontendRequestHandlers
|
|
166
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { validateClientMessage } = require("../wireValidation/index.cjs");
|
|
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, 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
|
+
};
|