@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matterhorn contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/host",
|
|
3
|
+
"version": "0.1.1-alpha.20260626T104441232Z",
|
|
4
|
+
"description": "Matterhorn host: room session runtime that serves connected clients and embeds a local relay.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.cjs",
|
|
9
|
+
"./src/*": "./src/*",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"qrcode-terminal": "^0.12.0",
|
|
14
|
+
"ws": "^8.21.0",
|
|
15
|
+
"@mh-gg/host-config": "^0.1.1-alpha.20260626T104441232Z",
|
|
16
|
+
"@mh-gg/host-ipc": "^0.1.1-alpha.20260626T104441232Z",
|
|
17
|
+
"@mh-gg/host-store": "^0.1.1-alpha.20260626T104441232Z",
|
|
18
|
+
"@mh-gg/relay": "^0.1.1-alpha.20260626T104441232Z",
|
|
19
|
+
"@mh-gg/room-link": "^0.1.1-alpha.20260626T104441232Z",
|
|
20
|
+
"@mh-gg/room-security": "^0.1.1-alpha.20260626T104441232Z"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.12"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"package.json"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/*.test.cjs",
|
|
33
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=60 --test-coverage-include=src/**/*.cjs test/*.test.cjs"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/host.cjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const WebSocket = require("ws");
|
|
6
|
+
const { ensureRoomSecurity } = require("@mh-gg/room-security");
|
|
7
|
+
const { parseArgs } = require("@mh-gg/host-config");
|
|
8
|
+
const { loadStore, saveStore } = require("@mh-gg/host-store");
|
|
9
|
+
const { normalizeAppUrl } = require("@mh-gg/room-link");
|
|
10
|
+
const { applymatterhornrcDefaults } = require("@mh-gg/relay");
|
|
11
|
+
const { ensureLocalRelay, relayIpcUrl } = require("./localRelayClient.cjs");
|
|
12
|
+
const { createHostSession } = require("./hostSession.cjs");
|
|
13
|
+
|
|
14
|
+
function roomAppFromEnv(env = process.env) {
|
|
15
|
+
if (!env.MATTERHORN_ROOM_APP) return undefined;
|
|
16
|
+
try {
|
|
17
|
+
const value = JSON.parse(env.MATTERHORN_ROOM_APP);
|
|
18
|
+
if (!value || typeof value !== "object") return undefined;
|
|
19
|
+
return value;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseJsonObjectEnv(value) {
|
|
26
|
+
if (!value) return undefined;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(value);
|
|
29
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function frontendBundleRebuildFromEnv(env) {
|
|
36
|
+
const rebuild = parseJsonObjectEnv(env.MATTERHORN_FRONTEND_BUNDLE_REBUILD);
|
|
37
|
+
return typeof rebuild?.command === "string" ? rebuild : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function frontendBundleManifestFromEnv(env) {
|
|
41
|
+
const manifestFile = env.MATTERHORN_FRONTEND_BUNDLE_MANIFEST_FILE ? path.resolve(env.MATTERHORN_FRONTEND_BUNDLE_MANIFEST_FILE) : undefined;
|
|
42
|
+
if (!manifestFile) return {};
|
|
43
|
+
try {
|
|
44
|
+
return { manifestFile, manifest: JSON.parse(fs.readFileSync(manifestFile, "utf8")) };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!error || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) globalThis.__matterhornIgnoredError?.(error, "host/host.cjs");
|
|
47
|
+
return { manifestFile };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function frontendBundleSourceRootsFromEnv(env) {
|
|
52
|
+
if (!env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_ROOTS) return undefined;
|
|
53
|
+
return env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_ROOTS.split(path.delimiter).filter(Boolean).map((item) => path.resolve(item));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function frontendBundleSourceIgnorePathsFromEnv(env) {
|
|
57
|
+
if (!env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_IGNORE_PATHS) return undefined;
|
|
58
|
+
return env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_IGNORE_PATHS.split(path.delimiter).filter(Boolean).map((item) => path.resolve(item));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function frontendBundleSourceRootFromEnv(env) {
|
|
62
|
+
if (!env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_ROOT) return undefined;
|
|
63
|
+
return path.resolve(env.MATTERHORN_FRONTEND_BUNDLE_SOURCE_ROOT);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function addOptionalFrontendBundleField(target, key, value) {
|
|
67
|
+
if (value !== undefined) target[key] = value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function frontendBundleFromEnv(env = process.env) {
|
|
71
|
+
if (!env.MATTERHORN_FRONTEND_BUNDLE_FILE) return undefined;
|
|
72
|
+
const manifestFromEnv = frontendBundleManifestFromEnv(env);
|
|
73
|
+
const sourceRoots = frontendBundleSourceRootsFromEnv(env);
|
|
74
|
+
const sourceIgnorePaths = frontendBundleSourceIgnorePathsFromEnv(env);
|
|
75
|
+
const sourceRoot = frontendBundleSourceRootFromEnv(env);
|
|
76
|
+
const rebuild = frontendBundleRebuildFromEnv(env);
|
|
77
|
+
const bundle = {
|
|
78
|
+
file: path.resolve(env.MATTERHORN_FRONTEND_BUNDLE_FILE),
|
|
79
|
+
id: env.MATTERHORN_FRONTEND_BUNDLE_ID || undefined,
|
|
80
|
+
integrity: env.MATTERHORN_FRONTEND_BUNDLE_INTEGRITY || undefined,
|
|
81
|
+
dynamic: env.MATTERHORN_FRONTEND_BUNDLE_DYNAMIC === "1"
|
|
82
|
+
};
|
|
83
|
+
addOptionalFrontendBundleField(bundle, "manifestFile", manifestFromEnv.manifestFile);
|
|
84
|
+
addOptionalFrontendBundleField(bundle, "manifest", manifestFromEnv.manifest);
|
|
85
|
+
addOptionalFrontendBundleField(bundle, "sourceRoot", sourceRoot);
|
|
86
|
+
addOptionalFrontendBundleField(bundle, "sourceRoots", sourceRoots);
|
|
87
|
+
addOptionalFrontendBundleField(bundle, "sourceIgnorePaths", sourceIgnorePaths);
|
|
88
|
+
addOptionalFrontendBundleField(bundle, "rebuild", rebuild);
|
|
89
|
+
return bundle;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function appRefFromEnv(env = process.env) {
|
|
93
|
+
if (!env.MATTERHORN_APP_REF) return undefined;
|
|
94
|
+
return {
|
|
95
|
+
appRef: env.MATTERHORN_APP_REF,
|
|
96
|
+
appCwd: env.MATTERHORN_APP_CWD || process.cwd()
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
applymatterhornrcDefaults();
|
|
102
|
+
const args = parseArgs(process.argv.slice(2), {
|
|
103
|
+
defaultDataDir: path.join(__dirname, "data")
|
|
104
|
+
});
|
|
105
|
+
const store = loadStore(args);
|
|
106
|
+
const appUrl = normalizeAppUrl(args.appUrl);
|
|
107
|
+
const localRelay = await ensureLocalRelay({
|
|
108
|
+
relayPeers: args.relayPeers,
|
|
109
|
+
matterhornProfile: args.matterhornProfile,
|
|
110
|
+
iceServers: args.iceServers,
|
|
111
|
+
localPeerjs: args.localPeerjs,
|
|
112
|
+
nondiscoverable: args.nondiscoverable,
|
|
113
|
+
dataDir: args.dataDir
|
|
114
|
+
});
|
|
115
|
+
const roomPeerId = localRelay.info.relayPeerId || localRelay.config.relayPeerId;
|
|
116
|
+
store.roomPeerId = roomPeerId;
|
|
117
|
+
store.relayAddress = localRelay.info.relayAddress || localRelay.config.relayAddress;
|
|
118
|
+
const roomApp = roomAppFromEnv();
|
|
119
|
+
if (roomApp) store.roomApp = roomApp;
|
|
120
|
+
ensureRoomSecurity(store);
|
|
121
|
+
saveStore(store);
|
|
122
|
+
|
|
123
|
+
const hostSession = createHostSession({
|
|
124
|
+
store,
|
|
125
|
+
args,
|
|
126
|
+
appUrl,
|
|
127
|
+
...appRefFromEnv(),
|
|
128
|
+
frontendBundle: frontendBundleFromEnv(),
|
|
129
|
+
localRelay,
|
|
130
|
+
roomPeerId,
|
|
131
|
+
saveStore,
|
|
132
|
+
reconnectRelay: true
|
|
133
|
+
});
|
|
134
|
+
const relaySocket = new WebSocket(relayIpcUrl(localRelay.config));
|
|
135
|
+
hostSession.attachRelaySocket(relaySocket);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (require.main === module) {
|
|
139
|
+
main().catch((error) => {
|
|
140
|
+
console.error(error);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
frontendBundleFromEnv,
|
|
147
|
+
appRefFromEnv,
|
|
148
|
+
main,
|
|
149
|
+
roomAppFromEnv
|
|
150
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
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("@mh-gg/relay");
|
|
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(`Profile: ${store.matterhornProfile || "development"}`);
|
|
46
|
+
logger.log(`Relay IPC: ws://${localRelay.config.ipcHost}:${localRelay.config.ipcPort}/room${localRelay.spawned ? " (started)" : " (existing)"}`);
|
|
47
|
+
logger.log(`Signaling: ${peerJsSignalingUrl()}`);
|
|
48
|
+
logger.log(`Launcher: ${appUrl}`);
|
|
49
|
+
if (store.roomApp?.appPack?.id) logger.log(`Room app: ${store.roomApp.appPack.id}@${store.roomApp.appPack.version || "unknown"}`);
|
|
50
|
+
const frontend = Array.isArray(store.roomApp?.frontends) ? store.roomApp.frontends[0] : undefined;
|
|
51
|
+
if (frontend?.id) logger.log(`Frontend: ${frontend.id}@${frontend.version || "unknown"} via ${frontend.delivery || "unknown"}`);
|
|
52
|
+
logger.log(`Data file: ${stateFile(store.dataDir, store.roomName)}`);
|
|
53
|
+
const invite = activePublicInvite(store);
|
|
54
|
+
let guestInviteAnnounced = false;
|
|
55
|
+
if (invite) {
|
|
56
|
+
if (store.adminBootstrapToken) {
|
|
57
|
+
const adminUrl = buildInvite(store, appUrl, roomPeerId, invite, store.adminBootstrapToken);
|
|
58
|
+
logger.log(`JOIN AS ADMIN: ${adminUrl}\n`);
|
|
59
|
+
}
|
|
60
|
+
guestInviteAnnounced = announceGuestInvite(options);
|
|
61
|
+
} else {
|
|
62
|
+
logger.log("Guest Invite: no active public invite\n");
|
|
63
|
+
}
|
|
64
|
+
logger.log("\nKeep this process running while guests are connected. Press Ctrl+C to stop.\n");
|
|
65
|
+
return { guestInviteAnnounced };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
announceGuestInvite,
|
|
70
|
+
announceHost
|
|
71
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
|
|
3
|
+
function readManifest(file) {
|
|
4
|
+
if (!file) return undefined;
|
|
5
|
+
try {
|
|
6
|
+
const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
7
|
+
if (manifest?.kind !== "matterhorn.frontend.manifest" || manifest.version !== 1) return undefined;
|
|
8
|
+
if (typeof manifest.entrypoint !== "string" || !manifest.entrypoint) return undefined;
|
|
9
|
+
if (!Array.isArray(manifest.files)) return undefined;
|
|
10
|
+
if (!manifest.files.some((item) => item?.path === "index.html")) return undefined;
|
|
11
|
+
return manifest;
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (!error || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
|
|
14
|
+
globalThis.__matterhornIgnoredError?.(error, "host/hostClients/frontendBundleManifest.cjs");
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sameManifest(left, right) {
|
|
21
|
+
if (left === right) return true;
|
|
22
|
+
if (!left || !right) return false;
|
|
23
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
readManifest,
|
|
28
|
+
sameManifest
|
|
29
|
+
};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const { createHash } = require("node:crypto");
|
|
3
|
+
const { spawn, 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
|
+
const { manifestBundleIdentity } = require("../../../../bin/appFrontend/artifacts.cjs");
|
|
9
|
+
const { readManifest, sameManifest } = require("./frontendBundleManifest.cjs");
|
|
10
|
+
const { latestSourceRootsMtime } = require("./sourceMtime.cjs");
|
|
11
|
+
|
|
12
|
+
function fileIntegrity(file) {
|
|
13
|
+
return `sha256-${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rebuildEnv(rebuild, options, policy) {
|
|
17
|
+
const env = Object.fromEntries(
|
|
18
|
+
Object.entries(rebuild.env || {}).map(([key, value]) => [key, replaceTokens(value, options)])
|
|
19
|
+
);
|
|
20
|
+
return buildMatterhornCommandEnv({
|
|
21
|
+
trust: commandEnvTrustForPolicy(policy || rebuild.trust || "trusted-local-dev"),
|
|
22
|
+
commandEnv: env,
|
|
23
|
+
port: options.port,
|
|
24
|
+
bundlePort: options.bundlePort,
|
|
25
|
+
mountPath: options.mountPath,
|
|
26
|
+
unsafeInheritEnv: rebuild.unsafeInheritEnv,
|
|
27
|
+
allowSensitiveCommandEnv: rebuild.allowSensitiveCommandEnv,
|
|
28
|
+
matterhornProfile: options.matterhornProfile
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rebuildOptions(frontendBundle, rebuild) {
|
|
33
|
+
return {
|
|
34
|
+
port: rebuild.port || 0,
|
|
35
|
+
bundlePort: rebuild.bundlePort || rebuild.port || 0,
|
|
36
|
+
mountPath: rebuild.mountPath || "/",
|
|
37
|
+
matterhornProfile: frontendBundle.matterhornProfile
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runRebuildCommand(command, options, policy) {
|
|
42
|
+
if (!command?.command) return true;
|
|
43
|
+
const args = Array.isArray(command.args) ? command.args.map((arg) => replaceTokens(arg, options)) : [];
|
|
44
|
+
const invocation = commandInvocation(command.command, args);
|
|
45
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
46
|
+
cwd: command.cwd || process.cwd(),
|
|
47
|
+
env: rebuildEnv(command, options, policy),
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
windowsHide: true
|
|
51
|
+
});
|
|
52
|
+
return result.status === 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runRebuildCommandAsync(command, options, policy) {
|
|
56
|
+
if (!command?.command) return Promise.resolve(true);
|
|
57
|
+
const args = Array.isArray(command.args) ? command.args.map((arg) => replaceTokens(arg, options)) : [];
|
|
58
|
+
const invocation = commandInvocation(command.command, args);
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
61
|
+
cwd: command.cwd || process.cwd(),
|
|
62
|
+
env: rebuildEnv(command, options, policy),
|
|
63
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
64
|
+
windowsHide: true
|
|
65
|
+
});
|
|
66
|
+
child.on("error", () => resolve(false));
|
|
67
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runDynamicBundleRebuild(frontendBundle) {
|
|
72
|
+
const rebuild = frontendBundle?.rebuild;
|
|
73
|
+
if (!rebuild?.command) return false;
|
|
74
|
+
const policy = assertAppCommandAllowed({ commandExecution: rebuild.trust || "trusted-local-dev" }, "matterhorn dynamic bundle rebuild");
|
|
75
|
+
const options = rebuildOptions(frontendBundle, rebuild);
|
|
76
|
+
for (const command of rebuild.before || []) {
|
|
77
|
+
if (!runRebuildCommand(command, options, policy)) return false;
|
|
78
|
+
}
|
|
79
|
+
return runRebuildCommand(rebuild, options, policy);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function runDynamicBundleRebuildAsync(frontendBundle) {
|
|
83
|
+
const rebuild = frontendBundle?.rebuild;
|
|
84
|
+
if (!rebuild?.command) return false;
|
|
85
|
+
const policy = assertAppCommandAllowed({ commandExecution: rebuild.trust || "trusted-local-dev" }, "matterhorn dynamic bundle rebuild");
|
|
86
|
+
const options = rebuildOptions(frontendBundle, rebuild);
|
|
87
|
+
for (const command of rebuild.before || []) {
|
|
88
|
+
if (!await runRebuildCommandAsync(command, options, policy)) return false;
|
|
89
|
+
}
|
|
90
|
+
return await runRebuildCommandAsync(rebuild, options, policy);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function dynamicSourceRoots(frontendBundle) {
|
|
94
|
+
if (!frontendBundle?.dynamic || !frontendBundle.sourceRoot || !frontendBundle.file) return [];
|
|
95
|
+
return (frontendBundle.sourceRoots || [frontendBundle.sourceRoot]).filter(Boolean).map((root) => path.resolve(root));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function dynamicBundleNeedsRebuild(frontendBundle) {
|
|
99
|
+
const roots = dynamicSourceRoots(frontendBundle);
|
|
100
|
+
if (roots.length === 0 || roots.every((root) => !fs.existsSync(root))) return false;
|
|
101
|
+
const bundleFile = path.resolve(frontendBundle.file);
|
|
102
|
+
const ignoredRoots = [
|
|
103
|
+
path.dirname(bundleFile),
|
|
104
|
+
...(frontendBundle.sourceIgnorePaths || [])
|
|
105
|
+
].filter(Boolean).map((item) => path.resolve(item));
|
|
106
|
+
const bundleMtime = fs.existsSync(bundleFile) ? fs.statSync(bundleFile).mtimeMs : 0;
|
|
107
|
+
return latestSourceRootsMtime(roots, ignoredRoots) > bundleMtime;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rebuildDynamicBundleIfSourceChanged(frontendBundle) {
|
|
111
|
+
if (!dynamicBundleNeedsRebuild(frontendBundle)) return false;
|
|
112
|
+
return runDynamicBundleRebuild(frontendBundle);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function dynamicBundleSnapshotStore(frontendBundle) {
|
|
116
|
+
if (!frontendBundle) return undefined;
|
|
117
|
+
if (!frontendBundle.dynamicBundleSnapshots) {
|
|
118
|
+
Object.defineProperty(frontendBundle, "dynamicBundleSnapshots", {
|
|
119
|
+
configurable: true,
|
|
120
|
+
enumerable: false,
|
|
121
|
+
value: new Map(),
|
|
122
|
+
writable: true
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return frontendBundle.dynamicBundleSnapshots;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function rememberDynamicBundleSnapshot(frontendBundle, frontend) {
|
|
129
|
+
if (!frontendBundle?.dynamic || !frontendBundle.file || !frontend || frontend.manifest || !frontend.integrity) return;
|
|
130
|
+
if (frontendBundle.id && frontendBundle.id !== frontend.id) return;
|
|
131
|
+
if (!fs.existsSync(frontendBundle.file)) return;
|
|
132
|
+
|
|
133
|
+
let stat;
|
|
134
|
+
let integrity;
|
|
135
|
+
try {
|
|
136
|
+
stat = fs.statSync(frontendBundle.file);
|
|
137
|
+
if (!stat.isFile()) return;
|
|
138
|
+
integrity = fileIntegrity(frontendBundle.file);
|
|
139
|
+
} catch {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (integrity !== frontend.integrity) return;
|
|
143
|
+
|
|
144
|
+
const snapshots = dynamicBundleSnapshotStore(frontendBundle);
|
|
145
|
+
if (!snapshots || snapshots.has(integrity)) return;
|
|
146
|
+
snapshots.set(integrity, {
|
|
147
|
+
buffer: fs.readFileSync(frontendBundle.file),
|
|
148
|
+
byteLength: stat.size,
|
|
149
|
+
chunkSize: frontend.chunkSize,
|
|
150
|
+
id: frontend.id,
|
|
151
|
+
integrity,
|
|
152
|
+
name: frontend.name
|
|
153
|
+
});
|
|
154
|
+
while (snapshots.size > 4) snapshots.delete(snapshots.keys().next().value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function frontendBundleIdentity(file, manifest) {
|
|
158
|
+
if (manifest) return manifestBundleIdentity(manifest);
|
|
159
|
+
const stat = fs.statSync(file);
|
|
160
|
+
if (!stat.isFile()) return undefined;
|
|
161
|
+
return {
|
|
162
|
+
integrity: fileIntegrity(file),
|
|
163
|
+
byteLength: stat.size
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createDynamicFrontendRefresher({ store, frontendBundle, onRoomAppChange = () => {} }) {
|
|
168
|
+
let pendingRebuild;
|
|
169
|
+
|
|
170
|
+
function refreshFrontendMetadata(frontend) {
|
|
171
|
+
if (!frontendBundle?.dynamic || !frontendBundle.file || !frontend) return frontend;
|
|
172
|
+
if (frontendBundle.id && frontendBundle.id !== frontend.id) return frontend;
|
|
173
|
+
if (!fs.existsSync(frontendBundle.file)) return frontend;
|
|
174
|
+
let identity;
|
|
175
|
+
try {
|
|
176
|
+
const manifest = readManifest(frontendBundle.manifestFile);
|
|
177
|
+
if (frontendBundle.manifestFile && !manifest) return frontend;
|
|
178
|
+
frontendBundle.manifest = manifest;
|
|
179
|
+
identity = frontendBundleIdentity(frontendBundle.file, frontendBundle.manifest);
|
|
180
|
+
if (!identity) return frontend;
|
|
181
|
+
} catch {
|
|
182
|
+
return frontend;
|
|
183
|
+
}
|
|
184
|
+
if (frontend.integrity === identity.integrity && frontend.byteLength === identity.byteLength && sameManifest(frontend.manifest, frontendBundle.manifest)) return frontend;
|
|
185
|
+
|
|
186
|
+
const frontends = Array.isArray(store.roomApp?.frontends) ? store.roomApp.frontends : [];
|
|
187
|
+
const index = frontends.findIndex((candidate) => candidate.id === frontend.id);
|
|
188
|
+
if (index < 0) return frontend;
|
|
189
|
+
const nextFrontend = {
|
|
190
|
+
...frontend,
|
|
191
|
+
integrity: identity.integrity,
|
|
192
|
+
byteLength: identity.byteLength,
|
|
193
|
+
...(frontendBundle.manifest ? { manifest: frontendBundle.manifest } : {})
|
|
194
|
+
};
|
|
195
|
+
store.roomApp = {
|
|
196
|
+
...store.roomApp,
|
|
197
|
+
frontends: frontends.map((candidate, candidateIndex) => candidateIndex === index ? nextFrontend : candidate)
|
|
198
|
+
};
|
|
199
|
+
onRoomAppChange(store.roomApp);
|
|
200
|
+
return nextFrontend;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function refreshDynamicFrontends(options = {}) {
|
|
204
|
+
const frontends = Array.isArray(store.roomApp?.frontends) ? store.roomApp.frontends : [];
|
|
205
|
+
for (const frontend of frontends) refreshDynamicFrontend(frontend, options);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function snapshotCurrentDynamicFrontends() {
|
|
209
|
+
const frontends = Array.isArray(store.roomApp?.frontends) ? store.roomApp.frontends : [];
|
|
210
|
+
for (const frontend of frontends) rememberDynamicBundleSnapshot(frontendBundle, frontend);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function scheduleDynamicBundleRebuild() {
|
|
214
|
+
if (pendingRebuild || !dynamicBundleNeedsRebuild(frontendBundle)) return;
|
|
215
|
+
snapshotCurrentDynamicFrontends();
|
|
216
|
+
pendingRebuild = runDynamicBundleRebuildAsync(frontendBundle)
|
|
217
|
+
.then((rebuilt) => {
|
|
218
|
+
if (rebuilt) refreshDynamicFrontends({ scheduleRebuild: false });
|
|
219
|
+
})
|
|
220
|
+
.catch((error) => {
|
|
221
|
+
(globalThis.__matterhornIgnoredError || (() => undefined))(error, "packages/matterhorn-host/src/hostClients/frontendBundleRefresh.cjs");
|
|
222
|
+
})
|
|
223
|
+
.finally(() => {
|
|
224
|
+
pendingRebuild = undefined;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function refreshDynamicFrontend(frontend, options = {}) {
|
|
229
|
+
if (!frontendBundle?.dynamic || !frontendBundle.file || !frontend) return frontend;
|
|
230
|
+
if (frontendBundle.id && frontendBundle.id !== frontend.id) return frontend;
|
|
231
|
+
if (options.scheduleRebuild !== false) scheduleDynamicBundleRebuild();
|
|
232
|
+
return refreshFrontendMetadata(frontend);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
refreshDynamicFrontend,
|
|
237
|
+
refreshDynamicFrontends
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
createDynamicFrontendRefresher,
|
|
243
|
+
rebuildEnv,
|
|
244
|
+
rebuildDynamicBundleIfSourceChanged,
|
|
245
|
+
runDynamicBundleRebuild
|
|
246
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function cachedFrontendProof(message) {
|
|
2
|
+
const proof = message?.cachedFrontend;
|
|
3
|
+
if (!proof || typeof proof !== "object" || Array.isArray(proof)) return undefined;
|
|
4
|
+
if (typeof proof.id !== "string" || !proof.id) return undefined;
|
|
5
|
+
if (typeof proof.integrity !== "string" || !proof.integrity) return undefined;
|
|
6
|
+
const result = { id: proof.id, integrity: proof.integrity };
|
|
7
|
+
if (Number.isInteger(proof.byteLength) && proof.byteLength >= 0) result.byteLength = proof.byteLength;
|
|
8
|
+
return result;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function frontendCacheStatus(frontend, message) {
|
|
12
|
+
const proof = cachedFrontendProof(message);
|
|
13
|
+
const sameFrontend = proof?.id === frontend.id && proof.integrity === frontend.integrity;
|
|
14
|
+
const sameLength = proof?.byteLength === undefined || frontend.byteLength === undefined || proof.byteLength === frontend.byteLength;
|
|
15
|
+
return {
|
|
16
|
+
frontendId: frontend.id,
|
|
17
|
+
integrity: frontend.integrity,
|
|
18
|
+
byteLength: frontend.byteLength,
|
|
19
|
+
clientHasCurrentBundle: Boolean(sameFrontend && sameLength)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function logFrontendCacheStatus({ debug, store, source, peerId, frontend, cache }) {
|
|
24
|
+
debug("frontend cache status", {
|
|
25
|
+
source,
|
|
26
|
+
peerId,
|
|
27
|
+
roomName: store.roomName,
|
|
28
|
+
frontendId: frontend.id,
|
|
29
|
+
integrity: frontend.integrity,
|
|
30
|
+
byteLength: frontend.byteLength,
|
|
31
|
+
clientHasCurrentBundle: cache.clientHasCurrentBundle
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = {
|
|
36
|
+
cachedFrontendProof,
|
|
37
|
+
frontendCacheStatus,
|
|
38
|
+
logFrontendCacheStatus
|
|
39
|
+
};
|