@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 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
@@ -0,0 +1,3 @@
1
+ # @mh-gg/host
2
+
3
+ Matterhorn host: the room session runtime that manages a room store, serves connected clients (frontend bundle transfer), and embeds/spawns a local relay. Depends on `@mh-gg/relay` for relay config, identity, wire-validation, and the embedded relay factory.
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,7 @@
1
+ const OPERATION_SNAPSHOT_INTERVAL = 50;
2
+ const DEFAULT_FRONTEND_CHUNK_BYTES = 6 * 1024;
3
+
4
+ module.exports = {
5
+ DEFAULT_FRONTEND_CHUNK_BYTES,
6
+ OPERATION_SNAPSHOT_INTERVAL
7
+ };
@@ -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
+ };