@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.
package/src/manager.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  CfsharePolicy,
24
24
  ExposureRecord,
25
25
  ExposureSession,
26
+ ExposureStats,
26
27
  ExposureStatus,
27
28
  ExposureType,
28
29
  FilePresentationMode,
@@ -140,6 +141,29 @@ type ManifestResponseMeta = {
140
141
  total_size_bytes: number;
141
142
  };
142
143
 
144
+ type PersistedSessionRecord = {
145
+ id: string;
146
+ type: ExposureType;
147
+ status: ExposureStatus;
148
+ createdAt: string;
149
+ expiresAt: string;
150
+ localUrl: string;
151
+ publicUrl?: string;
152
+ sourcePort?: number;
153
+ originPort: number;
154
+ tunnelPort: number;
155
+ workspaceDir?: string;
156
+ processPid?: number;
157
+ ownerPid?: number;
158
+ fileMode?: "normal" | "zip";
159
+ filePresentation?: FilePresentationMode;
160
+ maxDownloads?: number;
161
+ manifest?: ManifestEntry[];
162
+ access: AccessState;
163
+ stats: ExposureStats;
164
+ lastError?: string;
165
+ };
166
+
143
167
  function nowIso(): string {
144
168
  return toLocalIso(new Date());
145
169
  }
@@ -310,6 +334,19 @@ function ensureString(input: unknown): string | undefined {
310
334
  return trimmed || undefined;
311
335
  }
312
336
 
337
+ function asNumber(input: unknown): number | undefined {
338
+ if (typeof input === "number" && Number.isFinite(input)) {
339
+ return Math.trunc(input);
340
+ }
341
+ if (typeof input === "string" && input.trim()) {
342
+ const parsed = Number(input);
343
+ if (Number.isFinite(parsed)) {
344
+ return Math.trunc(parsed);
345
+ }
346
+ }
347
+ return undefined;
348
+ }
349
+
313
350
  function buildContentDisposition(params: {
314
351
  mode: FilePresentationMode;
315
352
  filePath: string;
@@ -727,6 +764,7 @@ export class CfshareManager {
727
764
  private readonly workspaceRoot: string;
728
765
  private readonly auditFile: string;
729
766
  private readonly sessionsFile: string;
767
+ private readonly sessionsDir: string;
730
768
  private readonly exportsDir: string;
731
769
 
732
770
  private initialized = false;
@@ -753,6 +791,7 @@ export class CfshareManager {
753
791
  this.workspaceRoot = path.join(this.stateDir, "workspaces");
754
792
  this.auditFile = path.join(this.stateDir, "audit.jsonl");
755
793
  this.sessionsFile = path.join(this.stateDir, "sessions.json");
794
+ this.sessionsDir = path.join(this.stateDir, "sessions");
756
795
  this.exportsDir = path.join(this.stateDir, "exports");
757
796
  this.cloudflaredPathInput = this.pluginConfig.cloudflaredPath ?? "cloudflared";
758
797
  }
@@ -771,6 +810,7 @@ export class CfshareManager {
771
810
  await mkdirp(this.stateDir);
772
811
  await mkdirp(this.workspaceRoot);
773
812
  await mkdirp(this.exportsDir);
813
+ await mkdirp(this.sessionsDir);
774
814
  await this.reloadPolicy();
775
815
  this.startGuard();
776
816
  this.initialized = true;
@@ -803,16 +843,213 @@ export class CfshareManager {
803
843
  }
804
844
  }
805
845
 
806
- private async persistSessionsSnapshot(): Promise<void> {
807
- const records = Array.from(this.sessions.values()).map((session) => ({
846
+ private sessionRecordPath(id: string): string {
847
+ return path.join(this.sessionsDir, `${encodeURIComponent(id)}.json`);
848
+ }
849
+
850
+ private toPersistedSessionRecord(session: ExposureSession): PersistedSessionRecord {
851
+ return {
808
852
  id: session.id,
809
853
  type: session.type,
810
854
  status: session.status,
855
+ createdAt: session.createdAt,
811
856
  expiresAt: session.expiresAt,
857
+ localUrl: session.localUrl,
858
+ publicUrl: session.publicUrl,
859
+ sourcePort: session.sourcePort,
860
+ originPort: session.originPort,
861
+ tunnelPort: session.tunnelPort,
812
862
  workspaceDir: session.workspaceDir,
813
863
  processPid: session.process?.pid,
814
- }));
815
- await fs.writeFile(this.sessionsFile, JSON.stringify(records, null, 2), "utf8");
864
+ ownerPid: process.pid,
865
+ fileMode: session.fileMode,
866
+ filePresentation: session.filePresentation,
867
+ maxDownloads: session.maxDownloads,
868
+ manifest: session.manifest,
869
+ access: session.access,
870
+ stats: session.stats,
871
+ lastError: session.lastError,
872
+ };
873
+ }
874
+
875
+ private parsePersistedSessionRecord(raw: unknown): PersistedSessionRecord | undefined {
876
+ if (!raw || typeof raw !== "object") {
877
+ return undefined;
878
+ }
879
+ const row = raw as Record<string, unknown>;
880
+ const id = ensureString(row.id);
881
+ const type = ensureString(row.type);
882
+ const status = ensureString(row.status);
883
+ if (!id || (type !== "port" && type !== "files")) {
884
+ return undefined;
885
+ }
886
+ const normalizedStatus: ExposureStatus =
887
+ status === "starting" || status === "running" || status === "stopped" || status === "error" || status === "expired"
888
+ ? status
889
+ : "error";
890
+ const accessRaw = (row.access ?? {}) as Record<string, unknown>;
891
+ const accessModeRaw = ensureString(accessRaw.mode);
892
+ const accessMode: AccessMode =
893
+ accessModeRaw === "token" || accessModeRaw === "basic" || accessModeRaw === "none" ? accessModeRaw : "none";
894
+ const statsRaw = (row.stats ?? {}) as Record<string, unknown>;
895
+ return {
896
+ id,
897
+ type,
898
+ status: normalizedStatus,
899
+ createdAt: ensureString(row.createdAt) || nowIso(),
900
+ expiresAt: ensureString(row.expiresAt) || nowIso(),
901
+ localUrl: ensureString(row.localUrl) || "",
902
+ publicUrl: ensureString(row.publicUrl) || undefined,
903
+ sourcePort: asNumber(row.sourcePort),
904
+ originPort: asNumber(row.originPort) ?? 0,
905
+ tunnelPort: asNumber(row.tunnelPort) ?? 0,
906
+ workspaceDir: ensureString(row.workspaceDir) || undefined,
907
+ processPid: asNumber(row.processPid),
908
+ ownerPid: asNumber(row.ownerPid),
909
+ fileMode: ensureString(row.fileMode) === "zip" ? "zip" : "normal",
910
+ filePresentation: normalizeFilePresentation(ensureString(row.filePresentation) || "download"),
911
+ maxDownloads: asNumber(row.maxDownloads),
912
+ manifest: Array.isArray(row.manifest) ? (row.manifest as ManifestEntry[]) : undefined,
913
+ access: {
914
+ mode: accessMode,
915
+ protectOrigin: Boolean(accessRaw.protectOrigin),
916
+ allowlistPaths: normalizeAllowlistPaths(accessRaw.allowlistPaths as string[] | undefined),
917
+ token: ensureString(accessRaw.token) || undefined,
918
+ username: ensureString(accessRaw.username) || undefined,
919
+ password: ensureString(accessRaw.password) || undefined,
920
+ },
921
+ stats: {
922
+ requests: asNumber(statsRaw.requests) ?? 0,
923
+ downloads: asNumber(statsRaw.downloads) ?? 0,
924
+ bytesSent: asNumber(statsRaw.bytesSent) ?? 0,
925
+ lastAccessAt: ensureString(statsRaw.lastAccessAt) || undefined,
926
+ },
927
+ lastError: ensureString(row.lastError) || undefined,
928
+ };
929
+ }
930
+
931
+ private async readPersistedSessions(): Promise<Map<string, PersistedSessionRecord>> {
932
+ const out = new Map<string, PersistedSessionRecord>();
933
+ let sessionsDirMissing = false;
934
+ const entries = await fs.readdir(this.sessionsDir, { withFileTypes: true }).catch((error) => {
935
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
936
+ sessionsDirMissing = true;
937
+ }
938
+ return [];
939
+ });
940
+ for (const entry of entries) {
941
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
942
+ continue;
943
+ }
944
+ const abs = path.join(this.sessionsDir, entry.name);
945
+ try {
946
+ const raw = await fs.readFile(abs, "utf8");
947
+ const record = this.parsePersistedSessionRecord(JSON.parse(raw));
948
+ if (record) {
949
+ out.set(record.id, record);
950
+ }
951
+ } catch {
952
+ // ignore malformed session files
953
+ }
954
+ }
955
+
956
+ if (out.size > 0 || !sessionsDirMissing) {
957
+ return out;
958
+ }
959
+
960
+ // Legacy fallback for snapshots generated before per-session persistence.
961
+ try {
962
+ const raw = await fs.readFile(this.sessionsFile, "utf8");
963
+ const rows = JSON.parse(raw);
964
+ if (!Array.isArray(rows)) {
965
+ return out;
966
+ }
967
+ for (const row of rows) {
968
+ const record = this.parsePersistedSessionRecord({
969
+ ...(row as Record<string, unknown>),
970
+ createdAt: (row as Record<string, unknown>).createdAt ?? nowIso(),
971
+ localUrl: (row as Record<string, unknown>).localUrl ?? "",
972
+ originPort: (row as Record<string, unknown>).originPort ?? 0,
973
+ tunnelPort: (row as Record<string, unknown>).tunnelPort ?? 0,
974
+ access:
975
+ (row as Record<string, unknown>).access ??
976
+ ({
977
+ mode: "none",
978
+ protectOrigin: false,
979
+ allowlistPaths: [],
980
+ } as AccessState),
981
+ stats:
982
+ (row as Record<string, unknown>).stats ??
983
+ ({
984
+ requests: 0,
985
+ downloads: 0,
986
+ bytesSent: 0,
987
+ } as ExposureStats),
988
+ });
989
+ if (record) {
990
+ out.set(record.id, record);
991
+ }
992
+ }
993
+ } catch {
994
+ // ignore missing legacy snapshot
995
+ }
996
+ return out;
997
+ }
998
+
999
+ private async writePersistedSessionRecord(record: PersistedSessionRecord): Promise<void> {
1000
+ const target = this.sessionRecordPath(record.id);
1001
+ const tmp = `${target}.${process.pid}.tmp`;
1002
+ await fs.writeFile(tmp, JSON.stringify(record, null, 2), "utf8");
1003
+ await fs.rename(tmp, target);
1004
+ }
1005
+
1006
+ private async deletePersistedSessionRecord(id: string): Promise<void> {
1007
+ await fs.rm(this.sessionRecordPath(id), { force: true });
1008
+ }
1009
+
1010
+ private isProcessAlive(pid: number | undefined): boolean {
1011
+ if (!pid || !Number.isInteger(pid) || pid <= 0) {
1012
+ return false;
1013
+ }
1014
+ try {
1015
+ process.kill(pid, 0);
1016
+ return true;
1017
+ } catch {
1018
+ return false;
1019
+ }
1020
+ }
1021
+
1022
+ private async persistSessionsSnapshot(): Promise<void> {
1023
+ const activeIds = new Set<string>();
1024
+ const records = Array.from(this.sessions.values()).map((session) => {
1025
+ const record = this.toPersistedSessionRecord(session);
1026
+ activeIds.add(record.id);
1027
+ return record;
1028
+ });
1029
+ await Promise.all(records.map((record) => this.writePersistedSessionRecord(record)));
1030
+
1031
+ const persisted = await this.readPersistedSessions();
1032
+ for (const record of persisted.values()) {
1033
+ if (record.ownerPid !== process.pid || activeIds.has(record.id)) {
1034
+ continue;
1035
+ }
1036
+ await this.deletePersistedSessionRecord(record.id);
1037
+ }
1038
+
1039
+ const mergedRows = Array.from((await this.readPersistedSessions()).values())
1040
+ .map((record) => ({
1041
+ id: record.id,
1042
+ type: record.type,
1043
+ status: record.status,
1044
+ expiresAt: record.expiresAt,
1045
+ workspaceDir: record.workspaceDir,
1046
+ processPid: record.processPid,
1047
+ ownerPid: record.ownerPid,
1048
+ publicUrl: record.publicUrl,
1049
+ localUrl: record.localUrl,
1050
+ }))
1051
+ .sort((a, b) => a.id.localeCompare(b.id));
1052
+ await fs.writeFile(this.sessionsFile, JSON.stringify(mergedRows, null, 2), "utf8");
816
1053
  }
817
1054
 
818
1055
  private makeAccessState(params: {
@@ -845,24 +1082,27 @@ export class CfshareManager {
845
1082
  };
846
1083
  }
847
1084
 
848
- private makeResponsePublicUrl(session: ExposureSession): string | undefined {
849
- const base = session.publicUrl;
1085
+ private makeResponsePublicUrlFromValues(base: string | undefined, access: AccessState): string | undefined {
850
1086
  if (!base) {
851
1087
  return undefined;
852
1088
  }
853
- if (session.access.mode !== "token" || !session.access.token) {
1089
+ if (access.mode !== "token" || !access.token) {
854
1090
  return base;
855
1091
  }
856
1092
  try {
857
1093
  const out = new URL(base);
858
- out.searchParams.set("token", session.access.token);
1094
+ out.searchParams.set("token", access.token);
859
1095
  return out.toString();
860
1096
  } catch {
861
1097
  const sep = base.includes("?") ? "&" : "?";
862
- return `${base}${sep}token=${encodeURIComponent(session.access.token)}`;
1098
+ return `${base}${sep}token=${encodeURIComponent(access.token)}`;
863
1099
  }
864
1100
  }
865
1101
 
1102
+ private makeResponsePublicUrl(session: Pick<ExposureSession, "publicUrl" | "access">): string | undefined {
1103
+ return this.makeResponsePublicUrlFromValues(session.publicUrl, session.access);
1104
+ }
1105
+
866
1106
  private buildRateLimiter(policy: RateLimitPolicy): (ip: string) => boolean {
867
1107
  if (!policy.enabled) {
868
1108
  return () => true;
@@ -2086,8 +2326,27 @@ export class CfshareManager {
2086
2326
  }
2087
2327
  }
2088
2328
 
2089
- exposureList(): ExposureRecord[] {
2090
- return Array.from(this.sessions.values()).map((session) => this.makeExposureRecord(session));
2329
+ async exposureList(): Promise<ExposureRecord[]> {
2330
+ await this.ensureInitialized();
2331
+ const persisted = await this.readPersistedSessions();
2332
+ const out = new Map<string, ExposureRecord>();
2333
+ for (const session of this.sessions.values()) {
2334
+ out.set(session.id, this.makeExposureRecord(session));
2335
+ }
2336
+ for (const record of persisted.values()) {
2337
+ if (out.has(record.id)) {
2338
+ continue;
2339
+ }
2340
+ out.set(record.id, {
2341
+ id: record.id,
2342
+ type: record.type,
2343
+ status: record.status,
2344
+ public_url: this.makeResponsePublicUrlFromValues(record.publicUrl, record.access),
2345
+ local_url: record.localUrl,
2346
+ expires_at: record.expiresAt,
2347
+ });
2348
+ }
2349
+ return Array.from(out.values()).sort((a, b) => a.id.localeCompare(b.id));
2091
2350
  }
2092
2351
 
2093
2352
  private normalizeRequestedIds(rawIds: string[]): string[] {
@@ -2107,36 +2366,81 @@ export class CfshareManager {
2107
2366
  return out;
2108
2367
  }
2109
2368
 
2110
- private matchesExposureFilter(session: ExposureSession, filter?: ExposureFilter): boolean {
2369
+ private matchesExposureFilterByValues(
2370
+ type: ExposureType,
2371
+ status: ExposureStatus,
2372
+ filter?: ExposureFilter,
2373
+ ): boolean {
2111
2374
  if (!filter) {
2112
2375
  return true;
2113
2376
  }
2114
- if (filter.status && session.status !== filter.status) {
2377
+ if (filter.status && status !== filter.status) {
2115
2378
  return false;
2116
2379
  }
2117
- if (filter.type && session.type !== filter.type) {
2380
+ if (filter.type && type !== filter.type) {
2118
2381
  return false;
2119
2382
  }
2120
2383
  return true;
2121
2384
  }
2122
2385
 
2386
+ private matchesExposureFilter(session: ExposureSession, filter?: ExposureFilter): boolean {
2387
+ return this.matchesExposureFilterByValues(session.type, session.status, filter);
2388
+ }
2389
+
2390
+ private async loadSessionLookupMap(): Promise<
2391
+ Map<string, { live?: ExposureSession; persisted?: PersistedSessionRecord }>
2392
+ > {
2393
+ const out = new Map<string, { live?: ExposureSession; persisted?: PersistedSessionRecord }>();
2394
+ for (const session of this.sessions.values()) {
2395
+ out.set(session.id, { live: session });
2396
+ }
2397
+ const persisted = await this.readPersistedSessions();
2398
+ for (const [id, record] of persisted.entries()) {
2399
+ const current = out.get(id);
2400
+ if (current) {
2401
+ current.persisted = record;
2402
+ } else {
2403
+ out.set(id, { persisted: record });
2404
+ }
2405
+ }
2406
+ return out;
2407
+ }
2408
+
2123
2409
  private resolveExposureSelection(query: {
2124
2410
  id?: string;
2125
2411
  ids?: string[];
2126
2412
  filter?: ExposureFilter;
2127
- }): { selectorUsed: boolean; selectedIds: string[]; missingIds: string[] } {
2413
+ }, lookupMap: Map<string, { live?: ExposureSession; persisted?: PersistedSessionRecord }>): {
2414
+ selectorUsed: boolean;
2415
+ selectedIds: string[];
2416
+ missingIds: string[];
2417
+ } {
2128
2418
  const explicitIds = this.normalizeRequestedIds([
2129
2419
  ...(typeof query.id === "string" ? [query.id] : []),
2130
2420
  ...((query.ids ?? []).filter((value): value is string => typeof value === "string")),
2131
2421
  ]);
2132
2422
 
2133
2423
  const hasAll = explicitIds.includes("all");
2134
- const allSessions = Array.from(this.sessions.values());
2424
+ const allIds = Array.from(lookupMap.keys());
2135
2425
 
2136
2426
  if (hasAll) {
2137
- const selectedIds = allSessions
2138
- .filter((session) => this.matchesExposureFilter(session, query.filter))
2139
- .map((session) => session.id);
2427
+ const selectedIds = allIds.filter((id) => {
2428
+ const lookup = lookupMap.get(id);
2429
+ if (!lookup) {
2430
+ return false;
2431
+ }
2432
+ if (lookup.live) {
2433
+ return this.matchesExposureFilter(lookup.live, query.filter);
2434
+ }
2435
+ if (lookup.persisted) {
2436
+ return this.matchesExposureFilterByValues(
2437
+ lookup.persisted.type,
2438
+ lookup.persisted.status,
2439
+ query.filter,
2440
+ );
2441
+ }
2442
+ return false;
2443
+ });
2140
2444
  return { selectorUsed: true, selectedIds, missingIds: [] };
2141
2445
  }
2142
2446
 
@@ -2144,12 +2448,19 @@ export class CfshareManager {
2144
2448
  const selectedIds: string[] = [];
2145
2449
  const missingIds: string[] = [];
2146
2450
  for (const id of explicitIds) {
2147
- const session = this.sessions.get(id);
2148
- if (!session) {
2451
+ const lookup = lookupMap.get(id);
2452
+ if (!lookup) {
2149
2453
  missingIds.push(id);
2150
2454
  continue;
2151
2455
  }
2152
- if (this.matchesExposureFilter(session, query.filter)) {
2456
+ if (lookup.live && this.matchesExposureFilter(lookup.live, query.filter)) {
2457
+ selectedIds.push(id);
2458
+ continue;
2459
+ }
2460
+ if (
2461
+ lookup.persisted &&
2462
+ this.matchesExposureFilterByValues(lookup.persisted.type, lookup.persisted.status, query.filter)
2463
+ ) {
2153
2464
  selectedIds.push(id);
2154
2465
  }
2155
2466
  }
@@ -2157,9 +2468,23 @@ export class CfshareManager {
2157
2468
  }
2158
2469
 
2159
2470
  if (query.filter) {
2160
- const selectedIds = allSessions
2161
- .filter((session) => this.matchesExposureFilter(session, query.filter))
2162
- .map((session) => session.id);
2471
+ const selectedIds = allIds.filter((id) => {
2472
+ const lookup = lookupMap.get(id);
2473
+ if (!lookup) {
2474
+ return false;
2475
+ }
2476
+ if (lookup.live) {
2477
+ return this.matchesExposureFilter(lookup.live, query.filter);
2478
+ }
2479
+ if (lookup.persisted) {
2480
+ return this.matchesExposureFilterByValues(
2481
+ lookup.persisted.type,
2482
+ lookup.persisted.status,
2483
+ query.filter,
2484
+ );
2485
+ }
2486
+ return false;
2487
+ });
2163
2488
  return { selectorUsed: true, selectedIds, missingIds: [] };
2164
2489
  }
2165
2490
 
@@ -2180,34 +2505,9 @@ export class CfshareManager {
2180
2505
  ? await probeLocalPort(session.sourcePort ?? session.originPort)
2181
2506
  : Boolean(session.originServer?.listening);
2182
2507
 
2183
- let publicProbe: { ok: boolean; status?: number; error?: string } | undefined;
2184
- if (opts?.probe_public && session.publicUrl) {
2185
- try {
2186
- const probeUrl = new URL(session.publicUrl);
2187
- if (session.access.mode === "token" && session.access.token) {
2188
- probeUrl.searchParams.set("token", session.access.token);
2189
- }
2190
-
2191
- const controller = new AbortController();
2192
- const timer = setTimeout(() => controller.abort(), DEFAULT_PROBE_TIMEOUT_MS);
2193
- const headers: Record<string, string> = {};
2194
- if (session.access.mode === "basic" && session.access.username && session.access.password) {
2195
- headers.authorization = `Basic ${Buffer.from(
2196
- `${session.access.username}:${session.access.password}`,
2197
- ).toString("base64")}`;
2198
- }
2199
-
2200
- const response = await fetch(probeUrl.toString(), {
2201
- method: "HEAD",
2202
- signal: controller.signal,
2203
- headers,
2204
- });
2205
- clearTimeout(timer);
2206
- publicProbe = { ok: response.ok, status: response.status };
2207
- } catch (error) {
2208
- publicProbe = { ok: false, error: String(error) };
2209
- }
2210
- }
2508
+ const publicProbe = opts?.probe_public
2509
+ ? await this.probePublicEndpoint(session.publicUrl, session.access)
2510
+ : undefined;
2211
2511
 
2212
2512
  const fileSharing =
2213
2513
  session.type === "files"
@@ -2251,6 +2551,98 @@ export class CfshareManager {
2251
2551
  return detail;
2252
2552
  }
2253
2553
 
2554
+ private async probePublicEndpoint(
2555
+ publicUrl: string | undefined,
2556
+ access: AccessState,
2557
+ ): Promise<{ ok: boolean; status?: number; error?: string } | undefined> {
2558
+ if (!publicUrl) {
2559
+ return undefined;
2560
+ }
2561
+ try {
2562
+ const probeUrl = new URL(publicUrl);
2563
+ if (access.mode === "token" && access.token) {
2564
+ probeUrl.searchParams.set("token", access.token);
2565
+ }
2566
+
2567
+ const controller = new AbortController();
2568
+ const timer = setTimeout(() => controller.abort(), DEFAULT_PROBE_TIMEOUT_MS);
2569
+ const headers: Record<string, string> = {};
2570
+ if (access.mode === "basic" && access.username && access.password) {
2571
+ headers.authorization = `Basic ${Buffer.from(
2572
+ `${access.username}:${access.password}`,
2573
+ ).toString("base64")}`;
2574
+ }
2575
+
2576
+ const response = await fetch(probeUrl.toString(), {
2577
+ method: "HEAD",
2578
+ signal: controller.signal,
2579
+ headers,
2580
+ });
2581
+ clearTimeout(timer);
2582
+ return { ok: response.ok, status: response.status };
2583
+ } catch (error) {
2584
+ return { ok: false, error: String(error) };
2585
+ }
2586
+ }
2587
+
2588
+ private async buildPersistedExposureDetail(
2589
+ record: PersistedSessionRecord,
2590
+ opts?: {
2591
+ probe_public?: boolean;
2592
+ include_manifest?: boolean;
2593
+ manifest_limit?: number;
2594
+ },
2595
+ ): Promise<Record<string, unknown>> {
2596
+ const tunnelAlive = this.isProcessAlive(record.processPid);
2597
+ const probePort =
2598
+ record.type === "port" ? (record.sourcePort ?? record.originPort) : record.originPort;
2599
+ const originAlive = probePort > 0 ? await probeLocalPort(probePort) : false;
2600
+ const publicProbe = opts?.probe_public
2601
+ ? await this.probePublicEndpoint(record.publicUrl, record.access)
2602
+ : undefined;
2603
+
2604
+ const fileSharing =
2605
+ record.type === "files"
2606
+ ? {
2607
+ mode: record.fileMode ?? "normal",
2608
+ presentation: record.filePresentation ?? "download",
2609
+ }
2610
+ : undefined;
2611
+ const includeManifest = Boolean(opts?.include_manifest);
2612
+ const manifestBundle =
2613
+ record.type === "files" ? this.makeManifestResponse(record.manifest, opts?.manifest_limit) : undefined;
2614
+
2615
+ const detail: Record<string, unknown> = {
2616
+ id: record.id,
2617
+ type: record.type,
2618
+ created_at: record.createdAt,
2619
+ status: {
2620
+ state: record.status,
2621
+ tunnel_alive: tunnelAlive,
2622
+ origin_alive: originAlive,
2623
+ public_probe: publicProbe,
2624
+ },
2625
+ port: {
2626
+ source_port: record.sourcePort,
2627
+ origin_port: record.originPort,
2628
+ tunnel_port: record.tunnelPort,
2629
+ },
2630
+ public_url: this.makeResponsePublicUrlFromValues(record.publicUrl, record.access),
2631
+ expires_at: record.expiresAt,
2632
+ local_url: record.localUrl,
2633
+ stats: record.stats,
2634
+ file_sharing: fileSharing,
2635
+ last_error: record.lastError,
2636
+ };
2637
+ if (manifestBundle) {
2638
+ detail.manifest_meta = manifestBundle.manifest_meta;
2639
+ if (includeManifest) {
2640
+ detail.manifest = manifestBundle.manifest;
2641
+ }
2642
+ }
2643
+ return detail;
2644
+ }
2645
+
2254
2646
  private projectExposureDetail(
2255
2647
  detail: Record<string, unknown>,
2256
2648
  fields?: ExposureGetField[],
@@ -2283,8 +2675,47 @@ export class CfshareManager {
2283
2675
  return out;
2284
2676
  }
2285
2677
 
2678
+ private async stopPersistedSession(
2679
+ record: PersistedSessionRecord,
2680
+ opts: { reason?: string; expired?: boolean } | undefined,
2681
+ cleaned: string[],
2682
+ ): Promise<void> {
2683
+ const uniquePids = Array.from(
2684
+ new Set([record.ownerPid, record.processPid].filter((pid): pid is number => Boolean(pid && pid > 0))),
2685
+ );
2686
+ for (const pid of uniquePids) {
2687
+ try {
2688
+ process.kill(pid, 0);
2689
+ process.kill(pid, "SIGTERM");
2690
+ } catch (error) {
2691
+ const errno = error as NodeJS.ErrnoException;
2692
+ if (errno?.code !== "ESRCH") {
2693
+ throw error;
2694
+ }
2695
+ }
2696
+ }
2697
+
2698
+ if (record.workspaceDir && (await fileExists(record.workspaceDir))) {
2699
+ await fs.rm(record.workspaceDir, { recursive: true, force: true });
2700
+ cleaned.push(record.workspaceDir);
2701
+ }
2702
+ await this.deletePersistedSessionRecord(record.id);
2703
+
2704
+ await this.writeAudit({
2705
+ ts: nowIso(),
2706
+ event: opts?.expired ? "exposure_expired" : "exposure_stopped",
2707
+ id: record.id,
2708
+ type: record.type,
2709
+ details: {
2710
+ reason: opts?.reason,
2711
+ public_url: record.publicUrl,
2712
+ },
2713
+ });
2714
+ }
2715
+
2286
2716
  async exposureGet(params: ExposureGetParams): Promise<Record<string, unknown>> {
2287
2717
  await this.ensureInitialized();
2718
+ const lookupMap = await this.loadSessionLookupMap();
2288
2719
 
2289
2720
  const fields = Array.isArray(params.fields) ? this.normalizeRequestedIds(params.fields) : undefined;
2290
2721
  const typedFields = fields as ExposureGetField[] | undefined;
@@ -2295,7 +2726,7 @@ export class CfshareManager {
2295
2726
  id: params.id,
2296
2727
  ids: params.ids,
2297
2728
  filter: params.filter,
2298
- });
2729
+ }, lookupMap);
2299
2730
 
2300
2731
  if (!selection.selectorUsed) {
2301
2732
  throw new Error("id, ids, or filter is required");
@@ -2303,11 +2734,18 @@ export class CfshareManager {
2303
2734
 
2304
2735
  if (legacySingle) {
2305
2736
  const legacyId = params.id as string;
2306
- const session = this.sessions.get(legacyId);
2307
- if (!session) {
2737
+ const lookup = lookupMap.get(legacyId);
2738
+ if (!lookup) {
2308
2739
  return { id: legacyId, status: "not_found" };
2309
2740
  }
2310
- return await this.buildExposureDetail(session, {
2741
+ if (lookup.live) {
2742
+ return await this.buildExposureDetail(lookup.live, {
2743
+ probe_public: params.opts?.probe_public,
2744
+ include_manifest: true,
2745
+ manifest_limit: MAX_RESPONSE_MANIFEST_ITEMS,
2746
+ });
2747
+ }
2748
+ return await this.buildPersistedExposureDetail(lookup.persisted as PersistedSessionRecord, {
2311
2749
  probe_public: params.opts?.probe_public,
2312
2750
  include_manifest: true,
2313
2751
  manifest_limit: MAX_RESPONSE_MANIFEST_ITEMS,
@@ -2323,16 +2761,22 @@ export class CfshareManager {
2323
2761
  const selectedIdsTruncated = selection.selectedIds.length > responseSelectedIds.length;
2324
2762
  const items: Record<string, unknown>[] = [];
2325
2763
  for (const id of responseSelectedIds) {
2326
- const session = this.sessions.get(id);
2327
- if (!session) {
2764
+ const lookup = lookupMap.get(id);
2765
+ if (!lookup) {
2328
2766
  items.push(this.makeExposureGetNotFound(id, typedFields));
2329
2767
  continue;
2330
2768
  }
2331
- const detail = await this.buildExposureDetail(session, {
2332
- probe_public: params.opts?.probe_public,
2333
- include_manifest: manifestRequested,
2334
- manifest_limit: manifestLimit,
2335
- });
2769
+ const detail = lookup.live
2770
+ ? await this.buildExposureDetail(lookup.live, {
2771
+ probe_public: params.opts?.probe_public,
2772
+ include_manifest: manifestRequested,
2773
+ manifest_limit: manifestLimit,
2774
+ })
2775
+ : await this.buildPersistedExposureDetail(lookup.persisted as PersistedSessionRecord, {
2776
+ probe_public: params.opts?.probe_public,
2777
+ include_manifest: manifestRequested,
2778
+ manifest_limit: manifestLimit,
2779
+ });
2336
2780
  items.push(this.projectExposureDetail(detail, typedFields));
2337
2781
  }
2338
2782
  for (const missingId of selection.missingIds) {
@@ -2356,6 +2800,7 @@ export class CfshareManager {
2356
2800
  opts?: { reason?: string; expired?: boolean; keepAudit?: boolean },
2357
2801
  ): Promise<Record<string, unknown>> {
2358
2802
  await this.ensureInitialized();
2803
+ const lookupMap = await this.loadSessionLookupMap();
2359
2804
 
2360
2805
  const requested = this.normalizeRequestedIds(Array.isArray(idOrIds) ? idOrIds : [idOrIds]);
2361
2806
  if (requested.length === 0) {
@@ -2367,13 +2812,13 @@ export class CfshareManager {
2367
2812
  const failed: Array<{ id: string; error: string }> = [];
2368
2813
 
2369
2814
  if (includeAll) {
2370
- ids.push(...Array.from(this.sessions.keys()));
2815
+ ids.push(...Array.from(lookupMap.keys()));
2371
2816
  if (ids.length === 0) {
2372
2817
  return { stopped: [], failed: [{ id: "all", error: "not_found" }], cleaned: [] };
2373
2818
  }
2374
2819
  } else {
2375
2820
  for (const id of requested) {
2376
- if (this.sessions.has(id)) {
2821
+ if (lookupMap.has(id)) {
2377
2822
  ids.push(id);
2378
2823
  } else {
2379
2824
  failed.push({ id, error: "not_found" });
@@ -2390,48 +2835,56 @@ export class CfshareManager {
2390
2835
  const cleaned: string[] = [];
2391
2836
 
2392
2837
  for (const id of stopIds) {
2393
- const session = this.sessions.get(id);
2394
- if (!session) {
2838
+ const lookup = lookupMap.get(id);
2839
+ if (!lookup) {
2395
2840
  failed.push({ id, error: "not_found" });
2396
2841
  continue;
2397
2842
  }
2843
+ const session = lookup.live;
2398
2844
  try {
2399
- if (session.timeoutHandle) {
2400
- clearTimeout(session.timeoutHandle);
2401
- session.timeoutHandle = undefined;
2402
- }
2845
+ if (session) {
2846
+ if (session.timeoutHandle) {
2847
+ clearTimeout(session.timeoutHandle);
2848
+ session.timeoutHandle = undefined;
2849
+ }
2403
2850
 
2404
- await this.terminateProcess(session.process);
2851
+ await this.terminateProcess(session.process);
2405
2852
 
2406
- if (session.proxyServer?.listening) {
2407
- await new Promise<void>((resolve) => session.proxyServer?.close(() => resolve()));
2408
- }
2409
- if (session.originServer?.listening) {
2410
- await new Promise<void>((resolve) => session.originServer?.close(() => resolve()));
2411
- }
2853
+ if (session.proxyServer?.listening) {
2854
+ await new Promise<void>((resolve) => session.proxyServer?.close(() => resolve()));
2855
+ }
2856
+ if (session.originServer?.listening) {
2857
+ await new Promise<void>((resolve) => session.originServer?.close(() => resolve()));
2858
+ }
2412
2859
 
2413
- if (session.workspaceDir && (await fileExists(session.workspaceDir))) {
2414
- await fs.rm(session.workspaceDir, { recursive: true, force: true });
2415
- cleaned.push(session.workspaceDir);
2416
- }
2860
+ if (session.workspaceDir && (await fileExists(session.workspaceDir))) {
2861
+ await fs.rm(session.workspaceDir, { recursive: true, force: true });
2862
+ cleaned.push(session.workspaceDir);
2863
+ }
2417
2864
 
2418
- session.status = opts?.expired ? "expired" : "stopped";
2419
- if (opts?.reason) {
2420
- session.lastError = opts.reason;
2421
- this.appendLog(session, "manager", `stop reason: ${opts.reason}`);
2865
+ session.status = opts?.expired ? "expired" : "stopped";
2866
+ if (opts?.reason) {
2867
+ session.lastError = opts.reason;
2868
+ this.appendLog(session, "manager", `stop reason: ${opts.reason}`);
2869
+ }
2870
+ stopped.push(id);
2871
+
2872
+ await this.writeAudit({
2873
+ ts: nowIso(),
2874
+ event: opts?.expired ? "exposure_expired" : "exposure_stopped",
2875
+ id: session.id,
2876
+ type: session.type,
2877
+ details: {
2878
+ reason: opts?.reason,
2879
+ public_url: session.publicUrl,
2880
+ },
2881
+ });
2882
+ } else if (lookup.persisted) {
2883
+ await this.stopPersistedSession(lookup.persisted, opts, cleaned);
2884
+ stopped.push(id);
2885
+ } else {
2886
+ failed.push({ id, error: "not_found" });
2422
2887
  }
2423
- stopped.push(id);
2424
-
2425
- await this.writeAudit({
2426
- ts: nowIso(),
2427
- event: opts?.expired ? "exposure_expired" : "exposure_stopped",
2428
- id: session.id,
2429
- type: session.type,
2430
- details: {
2431
- reason: opts?.reason,
2432
- public_url: session.publicUrl,
2433
- },
2434
- });
2435
2888
  } catch (error) {
2436
2889
  failed.push({ id, error: String(error) });
2437
2890
  } finally {
@@ -2611,11 +3064,13 @@ export class CfshareManager {
2611
3064
  private async runGc(): Promise<Record<string, unknown>> {
2612
3065
  const removedWorkspaces: string[] = [];
2613
3066
  const killedPids: number[] = [];
3067
+ const persisted = await this.readPersistedSessions();
2614
3068
 
2615
3069
  const activeWorkspaces = new Set(
2616
- Array.from(this.sessions.values())
2617
- .map((session) => session.workspaceDir)
2618
- .filter((entry): entry is string => Boolean(entry)),
3070
+ [
3071
+ ...Array.from(this.sessions.values()).map((session) => session.workspaceDir),
3072
+ ...Array.from(persisted.values()).map((session) => session.workspaceDir),
3073
+ ].filter((entry): entry is string => Boolean(entry)),
2619
3074
  );
2620
3075
 
2621
3076
  const workspaces = await fs.readdir(this.workspaceRoot, { withFileTypes: true }).catch(() => []);
@@ -2631,23 +3086,22 @@ export class CfshareManager {
2631
3086
  removedWorkspaces.push(abs);
2632
3087
  }
2633
3088
 
2634
- try {
2635
- const raw = await fs.readFile(this.sessionsFile, "utf8");
2636
- const rows = JSON.parse(raw) as Array<{ id?: string; processPid?: number; workspaceDir?: string }>;
2637
- for (const row of rows ?? []) {
2638
- if (!row.processPid || this.sessions.has(String(row.id ?? ""))) {
2639
- continue;
2640
- }
3089
+ for (const record of persisted.values()) {
3090
+ if (this.sessions.has(record.id)) {
3091
+ continue;
3092
+ }
3093
+ const pids = Array.from(
3094
+ new Set([record.ownerPid, record.processPid].filter((pid): pid is number => Boolean(pid && pid > 0))),
3095
+ );
3096
+ for (const pid of pids) {
2641
3097
  try {
2642
- process.kill(row.processPid, 0);
2643
- process.kill(row.processPid, "SIGTERM");
2644
- killedPids.push(row.processPid);
3098
+ process.kill(pid, 0);
3099
+ process.kill(pid, "SIGTERM");
3100
+ killedPids.push(pid);
2645
3101
  } catch {
2646
- // ignore
3102
+ // ignore dead pid
2647
3103
  }
2648
3104
  }
2649
- } catch {
2650
- // ignore missing snapshot
2651
3105
  }
2652
3106
 
2653
3107
  await this.persistSessionsSnapshot();