@ystemsrx/cfshare 0.1.5 → 0.1.6

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.
@@ -184,6 +184,18 @@ function ensureString(input) {
184
184
  const trimmed = input.trim();
185
185
  return trimmed || undefined;
186
186
  }
187
+ function asNumber(input) {
188
+ if (typeof input === "number" && Number.isFinite(input)) {
189
+ return Math.trunc(input);
190
+ }
191
+ if (typeof input === "string" && input.trim()) {
192
+ const parsed = Number(input);
193
+ if (Number.isFinite(parsed)) {
194
+ return Math.trunc(parsed);
195
+ }
196
+ }
197
+ return undefined;
198
+ }
187
199
  function buildContentDisposition(params) {
188
200
  if (params.mode === "raw") {
189
201
  return undefined;
@@ -558,6 +570,7 @@ export class CfshareManager {
558
570
  workspaceRoot;
559
571
  auditFile;
560
572
  sessionsFile;
573
+ sessionsDir;
561
574
  exportsDir;
562
575
  initialized = false;
563
576
  initializing;
@@ -577,6 +590,7 @@ export class CfshareManager {
577
590
  this.workspaceRoot = path.join(this.stateDir, "workspaces");
578
591
  this.auditFile = path.join(this.stateDir, "audit.jsonl");
579
592
  this.sessionsFile = path.join(this.stateDir, "sessions.json");
593
+ this.sessionsDir = path.join(this.stateDir, "sessions");
580
594
  this.exportsDir = path.join(this.stateDir, "exports");
581
595
  this.cloudflaredPathInput = this.pluginConfig.cloudflaredPath ?? "cloudflared";
582
596
  }
@@ -593,6 +607,7 @@ export class CfshareManager {
593
607
  await mkdirp(this.stateDir);
594
608
  await mkdirp(this.workspaceRoot);
595
609
  await mkdirp(this.exportsDir);
610
+ await mkdirp(this.sessionsDir);
596
611
  await this.reloadPolicy();
597
612
  this.startGuard();
598
613
  this.initialized = true;
@@ -622,16 +637,201 @@ export class CfshareManager {
622
637
  this.logger.warn(`cfshare: failed to write audit event: ${String(error)}`);
623
638
  }
624
639
  }
625
- async persistSessionsSnapshot() {
626
- const records = Array.from(this.sessions.values()).map((session) => ({
640
+ sessionRecordPath(id) {
641
+ return path.join(this.sessionsDir, `${encodeURIComponent(id)}.json`);
642
+ }
643
+ toPersistedSessionRecord(session) {
644
+ return {
627
645
  id: session.id,
628
646
  type: session.type,
629
647
  status: session.status,
648
+ createdAt: session.createdAt,
630
649
  expiresAt: session.expiresAt,
650
+ localUrl: session.localUrl,
651
+ publicUrl: session.publicUrl,
652
+ sourcePort: session.sourcePort,
653
+ originPort: session.originPort,
654
+ tunnelPort: session.tunnelPort,
631
655
  workspaceDir: session.workspaceDir,
632
656
  processPid: session.process?.pid,
633
- }));
634
- await fs.writeFile(this.sessionsFile, JSON.stringify(records, null, 2), "utf8");
657
+ ownerPid: process.pid,
658
+ fileMode: session.fileMode,
659
+ filePresentation: session.filePresentation,
660
+ maxDownloads: session.maxDownloads,
661
+ manifest: session.manifest,
662
+ access: session.access,
663
+ stats: session.stats,
664
+ lastError: session.lastError,
665
+ };
666
+ }
667
+ parsePersistedSessionRecord(raw) {
668
+ if (!raw || typeof raw !== "object") {
669
+ return undefined;
670
+ }
671
+ const row = raw;
672
+ const id = ensureString(row.id);
673
+ const type = ensureString(row.type);
674
+ const status = ensureString(row.status);
675
+ if (!id || (type !== "port" && type !== "files")) {
676
+ return undefined;
677
+ }
678
+ const normalizedStatus = status === "starting" || status === "running" || status === "stopped" || status === "error" || status === "expired"
679
+ ? status
680
+ : "error";
681
+ const accessRaw = (row.access ?? {});
682
+ const accessModeRaw = ensureString(accessRaw.mode);
683
+ const accessMode = accessModeRaw === "token" || accessModeRaw === "basic" || accessModeRaw === "none" ? accessModeRaw : "none";
684
+ const statsRaw = (row.stats ?? {});
685
+ return {
686
+ id,
687
+ type,
688
+ status: normalizedStatus,
689
+ createdAt: ensureString(row.createdAt) || nowIso(),
690
+ expiresAt: ensureString(row.expiresAt) || nowIso(),
691
+ localUrl: ensureString(row.localUrl) || "",
692
+ publicUrl: ensureString(row.publicUrl) || undefined,
693
+ sourcePort: asNumber(row.sourcePort),
694
+ originPort: asNumber(row.originPort) ?? 0,
695
+ tunnelPort: asNumber(row.tunnelPort) ?? 0,
696
+ workspaceDir: ensureString(row.workspaceDir) || undefined,
697
+ processPid: asNumber(row.processPid),
698
+ ownerPid: asNumber(row.ownerPid),
699
+ fileMode: ensureString(row.fileMode) === "zip" ? "zip" : "normal",
700
+ filePresentation: normalizeFilePresentation(ensureString(row.filePresentation) || "download"),
701
+ maxDownloads: asNumber(row.maxDownloads),
702
+ manifest: Array.isArray(row.manifest) ? row.manifest : undefined,
703
+ access: {
704
+ mode: accessMode,
705
+ protectOrigin: Boolean(accessRaw.protectOrigin),
706
+ allowlistPaths: normalizeAllowlistPaths(accessRaw.allowlistPaths),
707
+ token: ensureString(accessRaw.token) || undefined,
708
+ username: ensureString(accessRaw.username) || undefined,
709
+ password: ensureString(accessRaw.password) || undefined,
710
+ },
711
+ stats: {
712
+ requests: asNumber(statsRaw.requests) ?? 0,
713
+ downloads: asNumber(statsRaw.downloads) ?? 0,
714
+ bytesSent: asNumber(statsRaw.bytesSent) ?? 0,
715
+ lastAccessAt: ensureString(statsRaw.lastAccessAt) || undefined,
716
+ },
717
+ lastError: ensureString(row.lastError) || undefined,
718
+ };
719
+ }
720
+ async readPersistedSessions() {
721
+ const out = new Map();
722
+ let sessionsDirMissing = false;
723
+ const entries = await fs.readdir(this.sessionsDir, { withFileTypes: true }).catch((error) => {
724
+ if (error?.code === "ENOENT") {
725
+ sessionsDirMissing = true;
726
+ }
727
+ return [];
728
+ });
729
+ for (const entry of entries) {
730
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
731
+ continue;
732
+ }
733
+ const abs = path.join(this.sessionsDir, entry.name);
734
+ try {
735
+ const raw = await fs.readFile(abs, "utf8");
736
+ const record = this.parsePersistedSessionRecord(JSON.parse(raw));
737
+ if (record) {
738
+ out.set(record.id, record);
739
+ }
740
+ }
741
+ catch {
742
+ // ignore malformed session files
743
+ }
744
+ }
745
+ if (out.size > 0 || !sessionsDirMissing) {
746
+ return out;
747
+ }
748
+ // Legacy fallback for snapshots generated before per-session persistence.
749
+ try {
750
+ const raw = await fs.readFile(this.sessionsFile, "utf8");
751
+ const rows = JSON.parse(raw);
752
+ if (!Array.isArray(rows)) {
753
+ return out;
754
+ }
755
+ for (const row of rows) {
756
+ const record = this.parsePersistedSessionRecord({
757
+ ...row,
758
+ createdAt: row.createdAt ?? nowIso(),
759
+ localUrl: row.localUrl ?? "",
760
+ originPort: row.originPort ?? 0,
761
+ tunnelPort: row.tunnelPort ?? 0,
762
+ access: row.access ??
763
+ {
764
+ mode: "none",
765
+ protectOrigin: false,
766
+ allowlistPaths: [],
767
+ },
768
+ stats: row.stats ??
769
+ {
770
+ requests: 0,
771
+ downloads: 0,
772
+ bytesSent: 0,
773
+ },
774
+ });
775
+ if (record) {
776
+ out.set(record.id, record);
777
+ }
778
+ }
779
+ }
780
+ catch {
781
+ // ignore missing legacy snapshot
782
+ }
783
+ return out;
784
+ }
785
+ async writePersistedSessionRecord(record) {
786
+ const target = this.sessionRecordPath(record.id);
787
+ const tmp = `${target}.${process.pid}.tmp`;
788
+ await fs.writeFile(tmp, JSON.stringify(record, null, 2), "utf8");
789
+ await fs.rename(tmp, target);
790
+ }
791
+ async deletePersistedSessionRecord(id) {
792
+ await fs.rm(this.sessionRecordPath(id), { force: true });
793
+ }
794
+ isProcessAlive(pid) {
795
+ if (!pid || !Number.isInteger(pid) || pid <= 0) {
796
+ return false;
797
+ }
798
+ try {
799
+ process.kill(pid, 0);
800
+ return true;
801
+ }
802
+ catch {
803
+ return false;
804
+ }
805
+ }
806
+ async persistSessionsSnapshot() {
807
+ const activeIds = new Set();
808
+ const records = Array.from(this.sessions.values()).map((session) => {
809
+ const record = this.toPersistedSessionRecord(session);
810
+ activeIds.add(record.id);
811
+ return record;
812
+ });
813
+ await Promise.all(records.map((record) => this.writePersistedSessionRecord(record)));
814
+ const persisted = await this.readPersistedSessions();
815
+ for (const record of persisted.values()) {
816
+ if (record.ownerPid !== process.pid || activeIds.has(record.id)) {
817
+ continue;
818
+ }
819
+ await this.deletePersistedSessionRecord(record.id);
820
+ }
821
+ const mergedRows = Array.from((await this.readPersistedSessions()).values())
822
+ .map((record) => ({
823
+ id: record.id,
824
+ type: record.type,
825
+ status: record.status,
826
+ expiresAt: record.expiresAt,
827
+ workspaceDir: record.workspaceDir,
828
+ processPid: record.processPid,
829
+ ownerPid: record.ownerPid,
830
+ publicUrl: record.publicUrl,
831
+ localUrl: record.localUrl,
832
+ }))
833
+ .sort((a, b) => a.id.localeCompare(b.id));
834
+ await fs.writeFile(this.sessionsFile, JSON.stringify(mergedRows, null, 2), "utf8");
635
835
  }
636
836
  makeAccessState(params) {
637
837
  const allowlistPaths = normalizeAllowlistPaths(params.allowlistPaths);
@@ -658,24 +858,26 @@ export class CfshareManager {
658
858
  allowlistPaths,
659
859
  };
660
860
  }
661
- makeResponsePublicUrl(session) {
662
- const base = session.publicUrl;
861
+ makeResponsePublicUrlFromValues(base, access) {
663
862
  if (!base) {
664
863
  return undefined;
665
864
  }
666
- if (session.access.mode !== "token" || !session.access.token) {
865
+ if (access.mode !== "token" || !access.token) {
667
866
  return base;
668
867
  }
669
868
  try {
670
869
  const out = new URL(base);
671
- out.searchParams.set("token", session.access.token);
870
+ out.searchParams.set("token", access.token);
672
871
  return out.toString();
673
872
  }
674
873
  catch {
675
874
  const sep = base.includes("?") ? "&" : "?";
676
- return `${base}${sep}token=${encodeURIComponent(session.access.token)}`;
875
+ return `${base}${sep}token=${encodeURIComponent(access.token)}`;
677
876
  }
678
877
  }
878
+ makeResponsePublicUrl(session) {
879
+ return this.makeResponsePublicUrlFromValues(session.publicUrl, session.access);
880
+ }
679
881
  buildRateLimiter(policy) {
680
882
  if (!policy.enabled) {
681
883
  return () => true;
@@ -1702,8 +1904,27 @@ export class CfshareManager {
1702
1904
  throw error;
1703
1905
  }
1704
1906
  }
1705
- exposureList() {
1706
- return Array.from(this.sessions.values()).map((session) => this.makeExposureRecord(session));
1907
+ async exposureList() {
1908
+ await this.ensureInitialized();
1909
+ const persisted = await this.readPersistedSessions();
1910
+ const out = new Map();
1911
+ for (const session of this.sessions.values()) {
1912
+ out.set(session.id, this.makeExposureRecord(session));
1913
+ }
1914
+ for (const record of persisted.values()) {
1915
+ if (out.has(record.id)) {
1916
+ continue;
1917
+ }
1918
+ out.set(record.id, {
1919
+ id: record.id,
1920
+ type: record.type,
1921
+ status: record.status,
1922
+ public_url: this.makeResponsePublicUrlFromValues(record.publicUrl, record.access),
1923
+ local_url: record.localUrl,
1924
+ expires_at: record.expiresAt,
1925
+ });
1926
+ }
1927
+ return Array.from(out.values()).sort((a, b) => a.id.localeCompare(b.id));
1707
1928
  }
1708
1929
  normalizeRequestedIds(rawIds) {
1709
1930
  const out = [];
@@ -1721,50 +1942,95 @@ export class CfshareManager {
1721
1942
  }
1722
1943
  return out;
1723
1944
  }
1724
- matchesExposureFilter(session, filter) {
1945
+ matchesExposureFilterByValues(type, status, filter) {
1725
1946
  if (!filter) {
1726
1947
  return true;
1727
1948
  }
1728
- if (filter.status && session.status !== filter.status) {
1949
+ if (filter.status && status !== filter.status) {
1729
1950
  return false;
1730
1951
  }
1731
- if (filter.type && session.type !== filter.type) {
1952
+ if (filter.type && type !== filter.type) {
1732
1953
  return false;
1733
1954
  }
1734
1955
  return true;
1735
1956
  }
1736
- resolveExposureSelection(query) {
1957
+ matchesExposureFilter(session, filter) {
1958
+ return this.matchesExposureFilterByValues(session.type, session.status, filter);
1959
+ }
1960
+ async loadSessionLookupMap() {
1961
+ const out = new Map();
1962
+ for (const session of this.sessions.values()) {
1963
+ out.set(session.id, { live: session });
1964
+ }
1965
+ const persisted = await this.readPersistedSessions();
1966
+ for (const [id, record] of persisted.entries()) {
1967
+ const current = out.get(id);
1968
+ if (current) {
1969
+ current.persisted = record;
1970
+ }
1971
+ else {
1972
+ out.set(id, { persisted: record });
1973
+ }
1974
+ }
1975
+ return out;
1976
+ }
1977
+ resolveExposureSelection(query, lookupMap) {
1737
1978
  const explicitIds = this.normalizeRequestedIds([
1738
1979
  ...(typeof query.id === "string" ? [query.id] : []),
1739
1980
  ...((query.ids ?? []).filter((value) => typeof value === "string")),
1740
1981
  ]);
1741
1982
  const hasAll = explicitIds.includes("all");
1742
- const allSessions = Array.from(this.sessions.values());
1983
+ const allIds = Array.from(lookupMap.keys());
1743
1984
  if (hasAll) {
1744
- const selectedIds = allSessions
1745
- .filter((session) => this.matchesExposureFilter(session, query.filter))
1746
- .map((session) => session.id);
1985
+ const selectedIds = allIds.filter((id) => {
1986
+ const lookup = lookupMap.get(id);
1987
+ if (!lookup) {
1988
+ return false;
1989
+ }
1990
+ if (lookup.live) {
1991
+ return this.matchesExposureFilter(lookup.live, query.filter);
1992
+ }
1993
+ if (lookup.persisted) {
1994
+ return this.matchesExposureFilterByValues(lookup.persisted.type, lookup.persisted.status, query.filter);
1995
+ }
1996
+ return false;
1997
+ });
1747
1998
  return { selectorUsed: true, selectedIds, missingIds: [] };
1748
1999
  }
1749
2000
  if (explicitIds.length > 0) {
1750
2001
  const selectedIds = [];
1751
2002
  const missingIds = [];
1752
2003
  for (const id of explicitIds) {
1753
- const session = this.sessions.get(id);
1754
- if (!session) {
2004
+ const lookup = lookupMap.get(id);
2005
+ if (!lookup) {
1755
2006
  missingIds.push(id);
1756
2007
  continue;
1757
2008
  }
1758
- if (this.matchesExposureFilter(session, query.filter)) {
2009
+ if (lookup.live && this.matchesExposureFilter(lookup.live, query.filter)) {
2010
+ selectedIds.push(id);
2011
+ continue;
2012
+ }
2013
+ if (lookup.persisted &&
2014
+ this.matchesExposureFilterByValues(lookup.persisted.type, lookup.persisted.status, query.filter)) {
1759
2015
  selectedIds.push(id);
1760
2016
  }
1761
2017
  }
1762
2018
  return { selectorUsed: true, selectedIds, missingIds };
1763
2019
  }
1764
2020
  if (query.filter) {
1765
- const selectedIds = allSessions
1766
- .filter((session) => this.matchesExposureFilter(session, query.filter))
1767
- .map((session) => session.id);
2021
+ const selectedIds = allIds.filter((id) => {
2022
+ const lookup = lookupMap.get(id);
2023
+ if (!lookup) {
2024
+ return false;
2025
+ }
2026
+ if (lookup.live) {
2027
+ return this.matchesExposureFilter(lookup.live, query.filter);
2028
+ }
2029
+ if (lookup.persisted) {
2030
+ return this.matchesExposureFilterByValues(lookup.persisted.type, lookup.persisted.status, query.filter);
2031
+ }
2032
+ return false;
2033
+ });
1768
2034
  return { selectorUsed: true, selectedIds, missingIds: [] };
1769
2035
  }
1770
2036
  return { selectorUsed: false, selectedIds: [], missingIds: [] };
@@ -1774,31 +2040,9 @@ export class CfshareManager {
1774
2040
  const originAlive = session.type === "port"
1775
2041
  ? await probeLocalPort(session.sourcePort ?? session.originPort)
1776
2042
  : Boolean(session.originServer?.listening);
1777
- let publicProbe;
1778
- if (opts?.probe_public && session.publicUrl) {
1779
- try {
1780
- const probeUrl = new URL(session.publicUrl);
1781
- if (session.access.mode === "token" && session.access.token) {
1782
- probeUrl.searchParams.set("token", session.access.token);
1783
- }
1784
- const controller = new AbortController();
1785
- const timer = setTimeout(() => controller.abort(), DEFAULT_PROBE_TIMEOUT_MS);
1786
- const headers = {};
1787
- if (session.access.mode === "basic" && session.access.username && session.access.password) {
1788
- headers.authorization = `Basic ${Buffer.from(`${session.access.username}:${session.access.password}`).toString("base64")}`;
1789
- }
1790
- const response = await fetch(probeUrl.toString(), {
1791
- method: "HEAD",
1792
- signal: controller.signal,
1793
- headers,
1794
- });
1795
- clearTimeout(timer);
1796
- publicProbe = { ok: response.ok, status: response.status };
1797
- }
1798
- catch (error) {
1799
- publicProbe = { ok: false, error: String(error) };
1800
- }
1801
- }
2043
+ const publicProbe = opts?.probe_public
2044
+ ? await this.probePublicEndpoint(session.publicUrl, session.access)
2045
+ : undefined;
1802
2046
  const fileSharing = session.type === "files"
1803
2047
  ? {
1804
2048
  mode: session.fileMode ?? "normal",
@@ -1837,6 +2081,78 @@ export class CfshareManager {
1837
2081
  }
1838
2082
  return detail;
1839
2083
  }
2084
+ async probePublicEndpoint(publicUrl, access) {
2085
+ if (!publicUrl) {
2086
+ return undefined;
2087
+ }
2088
+ try {
2089
+ const probeUrl = new URL(publicUrl);
2090
+ if (access.mode === "token" && access.token) {
2091
+ probeUrl.searchParams.set("token", access.token);
2092
+ }
2093
+ const controller = new AbortController();
2094
+ const timer = setTimeout(() => controller.abort(), DEFAULT_PROBE_TIMEOUT_MS);
2095
+ const headers = {};
2096
+ if (access.mode === "basic" && access.username && access.password) {
2097
+ headers.authorization = `Basic ${Buffer.from(`${access.username}:${access.password}`).toString("base64")}`;
2098
+ }
2099
+ const response = await fetch(probeUrl.toString(), {
2100
+ method: "HEAD",
2101
+ signal: controller.signal,
2102
+ headers,
2103
+ });
2104
+ clearTimeout(timer);
2105
+ return { ok: response.ok, status: response.status };
2106
+ }
2107
+ catch (error) {
2108
+ return { ok: false, error: String(error) };
2109
+ }
2110
+ }
2111
+ async buildPersistedExposureDetail(record, opts) {
2112
+ const tunnelAlive = this.isProcessAlive(record.processPid);
2113
+ const probePort = record.type === "port" ? (record.sourcePort ?? record.originPort) : record.originPort;
2114
+ const originAlive = probePort > 0 ? await probeLocalPort(probePort) : false;
2115
+ const publicProbe = opts?.probe_public
2116
+ ? await this.probePublicEndpoint(record.publicUrl, record.access)
2117
+ : undefined;
2118
+ const fileSharing = record.type === "files"
2119
+ ? {
2120
+ mode: record.fileMode ?? "normal",
2121
+ presentation: record.filePresentation ?? "download",
2122
+ }
2123
+ : undefined;
2124
+ const includeManifest = Boolean(opts?.include_manifest);
2125
+ const manifestBundle = record.type === "files" ? this.makeManifestResponse(record.manifest, opts?.manifest_limit) : undefined;
2126
+ const detail = {
2127
+ id: record.id,
2128
+ type: record.type,
2129
+ created_at: record.createdAt,
2130
+ status: {
2131
+ state: record.status,
2132
+ tunnel_alive: tunnelAlive,
2133
+ origin_alive: originAlive,
2134
+ public_probe: publicProbe,
2135
+ },
2136
+ port: {
2137
+ source_port: record.sourcePort,
2138
+ origin_port: record.originPort,
2139
+ tunnel_port: record.tunnelPort,
2140
+ },
2141
+ public_url: this.makeResponsePublicUrlFromValues(record.publicUrl, record.access),
2142
+ expires_at: record.expiresAt,
2143
+ local_url: record.localUrl,
2144
+ stats: record.stats,
2145
+ file_sharing: fileSharing,
2146
+ last_error: record.lastError,
2147
+ };
2148
+ if (manifestBundle) {
2149
+ detail.manifest_meta = manifestBundle.manifest_meta;
2150
+ if (includeManifest) {
2151
+ detail.manifest = manifestBundle.manifest;
2152
+ }
2153
+ }
2154
+ return detail;
2155
+ }
1840
2156
  projectExposureDetail(detail, fields) {
1841
2157
  if (!fields || fields.length === 0) {
1842
2158
  return detail;
@@ -1864,8 +2180,39 @@ export class CfshareManager {
1864
2180
  }
1865
2181
  return out;
1866
2182
  }
2183
+ async stopPersistedSession(record, opts, cleaned) {
2184
+ const uniquePids = Array.from(new Set([record.ownerPid, record.processPid].filter((pid) => Boolean(pid && pid > 0))));
2185
+ for (const pid of uniquePids) {
2186
+ try {
2187
+ process.kill(pid, 0);
2188
+ process.kill(pid, "SIGTERM");
2189
+ }
2190
+ catch (error) {
2191
+ const errno = error;
2192
+ if (errno?.code !== "ESRCH") {
2193
+ throw error;
2194
+ }
2195
+ }
2196
+ }
2197
+ if (record.workspaceDir && (await fileExists(record.workspaceDir))) {
2198
+ await fs.rm(record.workspaceDir, { recursive: true, force: true });
2199
+ cleaned.push(record.workspaceDir);
2200
+ }
2201
+ await this.deletePersistedSessionRecord(record.id);
2202
+ await this.writeAudit({
2203
+ ts: nowIso(),
2204
+ event: opts?.expired ? "exposure_expired" : "exposure_stopped",
2205
+ id: record.id,
2206
+ type: record.type,
2207
+ details: {
2208
+ reason: opts?.reason,
2209
+ public_url: record.publicUrl,
2210
+ },
2211
+ });
2212
+ }
1867
2213
  async exposureGet(params) {
1868
2214
  await this.ensureInitialized();
2215
+ const lookupMap = await this.loadSessionLookupMap();
1869
2216
  const fields = Array.isArray(params.fields) ? this.normalizeRequestedIds(params.fields) : undefined;
1870
2217
  const typedFields = fields;
1871
2218
  const legacySingle = Boolean(params.id) && params.id !== "all" && !params.ids && !params.filter && !params.fields;
@@ -1873,17 +2220,24 @@ export class CfshareManager {
1873
2220
  id: params.id,
1874
2221
  ids: params.ids,
1875
2222
  filter: params.filter,
1876
- });
2223
+ }, lookupMap);
1877
2224
  if (!selection.selectorUsed) {
1878
2225
  throw new Error("id, ids, or filter is required");
1879
2226
  }
1880
2227
  if (legacySingle) {
1881
2228
  const legacyId = params.id;
1882
- const session = this.sessions.get(legacyId);
1883
- if (!session) {
2229
+ const lookup = lookupMap.get(legacyId);
2230
+ if (!lookup) {
1884
2231
  return { id: legacyId, status: "not_found" };
1885
2232
  }
1886
- return await this.buildExposureDetail(session, {
2233
+ if (lookup.live) {
2234
+ return await this.buildExposureDetail(lookup.live, {
2235
+ probe_public: params.opts?.probe_public,
2236
+ include_manifest: true,
2237
+ manifest_limit: MAX_RESPONSE_MANIFEST_ITEMS,
2238
+ });
2239
+ }
2240
+ return await this.buildPersistedExposureDetail(lookup.persisted, {
1887
2241
  probe_public: params.opts?.probe_public,
1888
2242
  include_manifest: true,
1889
2243
  manifest_limit: MAX_RESPONSE_MANIFEST_ITEMS,
@@ -1897,16 +2251,22 @@ export class CfshareManager {
1897
2251
  const selectedIdsTruncated = selection.selectedIds.length > responseSelectedIds.length;
1898
2252
  const items = [];
1899
2253
  for (const id of responseSelectedIds) {
1900
- const session = this.sessions.get(id);
1901
- if (!session) {
2254
+ const lookup = lookupMap.get(id);
2255
+ if (!lookup) {
1902
2256
  items.push(this.makeExposureGetNotFound(id, typedFields));
1903
2257
  continue;
1904
2258
  }
1905
- const detail = await this.buildExposureDetail(session, {
1906
- probe_public: params.opts?.probe_public,
1907
- include_manifest: manifestRequested,
1908
- manifest_limit: manifestLimit,
1909
- });
2259
+ const detail = lookup.live
2260
+ ? await this.buildExposureDetail(lookup.live, {
2261
+ probe_public: params.opts?.probe_public,
2262
+ include_manifest: manifestRequested,
2263
+ manifest_limit: manifestLimit,
2264
+ })
2265
+ : await this.buildPersistedExposureDetail(lookup.persisted, {
2266
+ probe_public: params.opts?.probe_public,
2267
+ include_manifest: manifestRequested,
2268
+ manifest_limit: manifestLimit,
2269
+ });
1910
2270
  items.push(this.projectExposureDetail(detail, typedFields));
1911
2271
  }
1912
2272
  for (const missingId of selection.missingIds) {
@@ -1925,6 +2285,7 @@ export class CfshareManager {
1925
2285
  }
1926
2286
  async stopExposure(idOrIds, opts) {
1927
2287
  await this.ensureInitialized();
2288
+ const lookupMap = await this.loadSessionLookupMap();
1928
2289
  const requested = this.normalizeRequestedIds(Array.isArray(idOrIds) ? idOrIds : [idOrIds]);
1929
2290
  if (requested.length === 0) {
1930
2291
  return { stopped: [], failed: [{ id: "unknown", error: "id or ids is required" }], cleaned: [] };
@@ -1933,14 +2294,14 @@ export class CfshareManager {
1933
2294
  const ids = [];
1934
2295
  const failed = [];
1935
2296
  if (includeAll) {
1936
- ids.push(...Array.from(this.sessions.keys()));
2297
+ ids.push(...Array.from(lookupMap.keys()));
1937
2298
  if (ids.length === 0) {
1938
2299
  return { stopped: [], failed: [{ id: "all", error: "not_found" }], cleaned: [] };
1939
2300
  }
1940
2301
  }
1941
2302
  else {
1942
2303
  for (const id of requested) {
1943
- if (this.sessions.has(id)) {
2304
+ if (lookupMap.has(id)) {
1944
2305
  ids.push(id);
1945
2306
  }
1946
2307
  else {
@@ -1955,43 +2316,53 @@ export class CfshareManager {
1955
2316
  const stopped = [];
1956
2317
  const cleaned = [];
1957
2318
  for (const id of stopIds) {
1958
- const session = this.sessions.get(id);
1959
- if (!session) {
2319
+ const lookup = lookupMap.get(id);
2320
+ if (!lookup) {
1960
2321
  failed.push({ id, error: "not_found" });
1961
2322
  continue;
1962
2323
  }
2324
+ const session = lookup.live;
1963
2325
  try {
1964
- if (session.timeoutHandle) {
1965
- clearTimeout(session.timeoutHandle);
1966
- session.timeoutHandle = undefined;
1967
- }
1968
- await this.terminateProcess(session.process);
1969
- if (session.proxyServer?.listening) {
1970
- await new Promise((resolve) => session.proxyServer?.close(() => resolve()));
1971
- }
1972
- if (session.originServer?.listening) {
1973
- await new Promise((resolve) => session.originServer?.close(() => resolve()));
2326
+ if (session) {
2327
+ if (session.timeoutHandle) {
2328
+ clearTimeout(session.timeoutHandle);
2329
+ session.timeoutHandle = undefined;
2330
+ }
2331
+ await this.terminateProcess(session.process);
2332
+ if (session.proxyServer?.listening) {
2333
+ await new Promise((resolve) => session.proxyServer?.close(() => resolve()));
2334
+ }
2335
+ if (session.originServer?.listening) {
2336
+ await new Promise((resolve) => session.originServer?.close(() => resolve()));
2337
+ }
2338
+ if (session.workspaceDir && (await fileExists(session.workspaceDir))) {
2339
+ await fs.rm(session.workspaceDir, { recursive: true, force: true });
2340
+ cleaned.push(session.workspaceDir);
2341
+ }
2342
+ session.status = opts?.expired ? "expired" : "stopped";
2343
+ if (opts?.reason) {
2344
+ session.lastError = opts.reason;
2345
+ this.appendLog(session, "manager", `stop reason: ${opts.reason}`);
2346
+ }
2347
+ stopped.push(id);
2348
+ await this.writeAudit({
2349
+ ts: nowIso(),
2350
+ event: opts?.expired ? "exposure_expired" : "exposure_stopped",
2351
+ id: session.id,
2352
+ type: session.type,
2353
+ details: {
2354
+ reason: opts?.reason,
2355
+ public_url: session.publicUrl,
2356
+ },
2357
+ });
1974
2358
  }
1975
- if (session.workspaceDir && (await fileExists(session.workspaceDir))) {
1976
- await fs.rm(session.workspaceDir, { recursive: true, force: true });
1977
- cleaned.push(session.workspaceDir);
2359
+ else if (lookup.persisted) {
2360
+ await this.stopPersistedSession(lookup.persisted, opts, cleaned);
2361
+ stopped.push(id);
1978
2362
  }
1979
- session.status = opts?.expired ? "expired" : "stopped";
1980
- if (opts?.reason) {
1981
- session.lastError = opts.reason;
1982
- this.appendLog(session, "manager", `stop reason: ${opts.reason}`);
2363
+ else {
2364
+ failed.push({ id, error: "not_found" });
1983
2365
  }
1984
- stopped.push(id);
1985
- await this.writeAudit({
1986
- ts: nowIso(),
1987
- event: opts?.expired ? "exposure_expired" : "exposure_stopped",
1988
- id: session.id,
1989
- type: session.type,
1990
- details: {
1991
- reason: opts?.reason,
1992
- public_url: session.publicUrl,
1993
- },
1994
- });
1995
2366
  }
1996
2367
  catch (error) {
1997
2368
  failed.push({ id, error: String(error) });
@@ -2143,9 +2514,11 @@ export class CfshareManager {
2143
2514
  async runGc() {
2144
2515
  const removedWorkspaces = [];
2145
2516
  const killedPids = [];
2146
- const activeWorkspaces = new Set(Array.from(this.sessions.values())
2147
- .map((session) => session.workspaceDir)
2148
- .filter((entry) => Boolean(entry)));
2517
+ const persisted = await this.readPersistedSessions();
2518
+ const activeWorkspaces = new Set([
2519
+ ...Array.from(this.sessions.values()).map((session) => session.workspaceDir),
2520
+ ...Array.from(persisted.values()).map((session) => session.workspaceDir),
2521
+ ].filter((entry) => Boolean(entry)));
2149
2522
  const workspaces = await fs.readdir(this.workspaceRoot, { withFileTypes: true }).catch(() => []);
2150
2523
  for (const entry of workspaces) {
2151
2524
  if (!entry.isDirectory()) {
@@ -2158,26 +2531,22 @@ export class CfshareManager {
2158
2531
  await fs.rm(abs, { recursive: true, force: true });
2159
2532
  removedWorkspaces.push(abs);
2160
2533
  }
2161
- try {
2162
- const raw = await fs.readFile(this.sessionsFile, "utf8");
2163
- const rows = JSON.parse(raw);
2164
- for (const row of rows ?? []) {
2165
- if (!row.processPid || this.sessions.has(String(row.id ?? ""))) {
2166
- continue;
2167
- }
2534
+ for (const record of persisted.values()) {
2535
+ if (this.sessions.has(record.id)) {
2536
+ continue;
2537
+ }
2538
+ const pids = Array.from(new Set([record.ownerPid, record.processPid].filter((pid) => Boolean(pid && pid > 0))));
2539
+ for (const pid of pids) {
2168
2540
  try {
2169
- process.kill(row.processPid, 0);
2170
- process.kill(row.processPid, "SIGTERM");
2171
- killedPids.push(row.processPid);
2541
+ process.kill(pid, 0);
2542
+ process.kill(pid, "SIGTERM");
2543
+ killedPids.push(pid);
2172
2544
  }
2173
2545
  catch {
2174
- // ignore
2546
+ // ignore dead pid
2175
2547
  }
2176
2548
  }
2177
2549
  }
2178
- catch {
2179
- // ignore missing snapshot
2180
- }
2181
2550
  await this.persistSessionsSnapshot();
2182
2551
  await this.writeAudit({
2183
2552
  ts: nowIso(),