@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.
@@ -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 };