@tomflow/proflow-dev-tunnel 0.1.2 → 0.1.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @tomflow/proflow-dev-tunnel
2
2
 
3
+ ## 0.1.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Detach the managed dev-tunnel host from the short-lived Platform CLI process and persist an ownership-checked process record so later status/stop/restart invocations can manage the same tunnel without turning `platform start` into a daemon.
8
+
9
+ ## 0.1.3
10
+
11
+ ### Patch Changes
12
+
13
+ - Bind production dev-tunnel verification to an explicit evidence file for the frozen file-relay and 429/5xx proofs, while retaining live HTTPS/TLS/latency/size checks.
14
+
3
15
  ## 0.1.2
4
16
 
5
17
  ### Patch Changes
@@ -1,5 +1,14 @@
1
1
  import type { ModuleOperationResult } from "@tomflow/proflow-module-contract";
2
2
  import type { DevTunnelRuntime, ErrorSemanticsProof, FileRelayProof } from "../src/resource-adapter.ts";
3
+ type DevTunnelVerificationEvidence = {
4
+ contract: "proflow.dev-tunnel-verification.v1";
5
+ moduleVersion: string;
6
+ publicBaseUrl: string;
7
+ observedAt: string;
8
+ fileRelay: FileRelayProof;
9
+ errorSemantics: ErrorSemanticsProof;
10
+ };
11
+ export declare function readDevTunnelVerificationEvidence(file: string | undefined, publicBaseUrl: string): Promise<DevTunnelVerificationEvidence | undefined>;
3
12
  export declare function createBehaviorAdapter(input?: {
4
13
  runtime: DevTunnelRuntime;
5
14
  verifyErrorSemantics?: () => Promise<ErrorSemanticsProof>;
@@ -125,6 +134,8 @@ export declare const behaviorAdapter: {
125
134
  export declare function createProductionBinding(input: {
126
135
  moduleRef: string;
127
136
  config: Record<string, string>;
137
+ workspaceRoot: string;
128
138
  }): Promise<{
129
139
  behaviorAdapter: Record<string, unknown>;
130
140
  } | undefined>;
141
+ export {};
@@ -1,3 +1,5 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
1
3
  import { verifyPublicIngress } from "../src/resource-adapter.js";
2
4
  import { descriptor } from "./descriptor.js";
3
5
  const success = (data) => ({
@@ -16,6 +18,32 @@ const actionRequired = (action, description) => ({
16
18
  moduleVersion: descriptor.moduleVersion,
17
19
  actionRequired: { action, description },
18
20
  });
21
+ export async function readDevTunnelVerificationEvidence(file, publicBaseUrl) {
22
+ if (!file)
23
+ return undefined;
24
+ try {
25
+ const raw = JSON.parse(await readFile(file, "utf8"));
26
+ if (raw.contract !== "proflow.dev-tunnel-verification.v1" ||
27
+ raw.moduleVersion !== descriptor.moduleVersion ||
28
+ raw.publicBaseUrl !== publicBaseUrl ||
29
+ typeof raw.observedAt !== "string" ||
30
+ Number.isNaN(Date.parse(raw.observedAt)) ||
31
+ typeof raw.fileRelay !== "object" ||
32
+ raw.fileRelay === null ||
33
+ typeof raw.fileRelay.verified !== "boolean" ||
34
+ typeof raw.fileRelay.message !== "string" ||
35
+ typeof raw.errorSemantics !== "object" ||
36
+ raw.errorSemantics === null ||
37
+ typeof raw.errorSemantics.rateLimit429Verified !== "boolean" ||
38
+ typeof raw.errorSemantics.server5xxVerified !== "boolean" ||
39
+ typeof raw.errorSemantics.message !== "string")
40
+ return undefined;
41
+ return raw;
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
19
47
  export function createBehaviorAdapter(input) {
20
48
  return {
21
49
  describe: () => ({
@@ -257,12 +285,23 @@ export async function createProductionBinding(input) {
257
285
  if (!publicBaseUrl)
258
286
  return undefined;
259
287
  const { createDevTunnelRuntime } = await import("../src/resource-adapter.js");
288
+ const readEvidence = () => readDevTunnelVerificationEvidence(input.config.verificationEvidenceFile, publicBaseUrl);
260
289
  return {
261
290
  behaviorAdapter: createBehaviorAdapter({
262
291
  runtime: createDevTunnelRuntime({
263
292
  publicBaseUrl,
264
293
  ...(input.config.tunnelId ? { tunnelId: input.config.tunnelId } : {}),
294
+ processStateFile: join(input.workspaceRoot, ".proflow", "runtime", "external-resources", "dev-tunnel", "process.json"),
265
295
  }),
296
+ verifyFileRelay: async () => (await readEvidence())?.fileRelay ?? {
297
+ verified: false,
298
+ message: "dev-tunnel verification evidence is missing, stale, or invalid",
299
+ },
300
+ verifyErrorSemantics: async () => (await readEvidence())?.errorSemantics ?? {
301
+ rateLimit429Verified: false,
302
+ server5xxVerified: false,
303
+ message: "dev-tunnel verification evidence is missing, stale, or invalid",
304
+ },
266
305
  }),
267
306
  };
268
307
  }
@@ -3,7 +3,7 @@ export declare const descriptor: {
3
3
  readonly contractVersion: "1.0.0";
4
4
  readonly moduleRef: "dev-tunnel";
5
5
  readonly packageName: "@tomflow/proflow-dev-tunnel";
6
- readonly moduleVersion: "0.1.2";
6
+ readonly moduleVersion: "0.1.4";
7
7
  readonly kind: "external-resource";
8
8
  readonly templateVersion: "1.0.0";
9
9
  readonly platformCompatibility: ">=1.0.0 <2.0.0";
@@ -31,6 +31,11 @@ export declare const descriptor: {
31
31
  readonly type: "string";
32
32
  readonly required: false;
33
33
  readonly description: "Persistent Microsoft Dev Tunnel identifier used when this adapter owns lifecycle start/stop";
34
+ }, {
35
+ readonly key: "verificationEvidenceFile";
36
+ readonly type: "path";
37
+ readonly required: false;
38
+ readonly description: "JSON evidence for real file-relay and 429/5xx verification behind the configured public ingress";
34
39
  }];
35
40
  readonly lifecycle: {
36
41
  readonly supported: readonly ["describe", "preflight", "status", "verify", "doctor", "start", "stop", "restart", "uninstall"];
@@ -3,7 +3,7 @@ export const descriptor = {
3
3
  contractVersion: "1.0.0",
4
4
  moduleRef: "dev-tunnel",
5
5
  packageName: "@tomflow/proflow-dev-tunnel",
6
- moduleVersion: "0.1.2",
6
+ moduleVersion: "0.1.4",
7
7
  kind: "external-resource",
8
8
  templateVersion: "1.0.0",
9
9
  platformCompatibility: ">=1.0.0 <2.0.0",
@@ -34,6 +34,12 @@ export const descriptor = {
34
34
  required: false,
35
35
  description: "Persistent Microsoft Dev Tunnel identifier used when this adapter owns lifecycle start/stop",
36
36
  },
37
+ {
38
+ key: "verificationEvidenceFile",
39
+ type: "path",
40
+ required: false,
41
+ description: "JSON evidence for real file-relay and 429/5xx verification behind the configured public ingress",
42
+ },
37
43
  ],
38
44
  lifecycle: {
39
45
  supported: [
@@ -27,6 +27,7 @@ export declare function createDevTunnelRuntime(input: {
27
27
  tunnelId?: string;
28
28
  publicBaseUrl?: string;
29
29
  runCommand?: CommandRunner;
30
+ processStateFile?: string;
30
31
  }): DevTunnelRuntime;
31
32
  export interface IngressCheck {
32
33
  id: string;
@@ -1,4 +1,6 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
2
4
  import { connect } from "node:tls";
3
5
  const LOGIN_ARGS = ["user", "show"];
4
6
  const LOGIN_TIMEOUT_MS = 10_000;
@@ -31,10 +33,83 @@ function defaultCommandRunner(command, args, options) {
31
33
  });
32
34
  });
33
35
  }
36
+ async function readProcessRecord(file) {
37
+ if (!file)
38
+ return undefined;
39
+ try {
40
+ const raw = JSON.parse(await readFile(file, "utf8"));
41
+ if (raw.contract !== "proflow.dev-tunnel-process.v1" ||
42
+ typeof raw.pid !== "number" ||
43
+ !Number.isInteger(raw.pid) ||
44
+ raw.pid <= 0 ||
45
+ typeof raw.command !== "string" ||
46
+ raw.command.length === 0 ||
47
+ typeof raw.tunnelId !== "string" ||
48
+ raw.tunnelId.length === 0 ||
49
+ typeof raw.startedAt !== "string" ||
50
+ Number.isNaN(Date.parse(raw.startedAt)))
51
+ return undefined;
52
+ return raw;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ async function writeProcessRecord(file, record) {
59
+ await mkdir(dirname(file), { recursive: true });
60
+ const temporary = `${file}.${process.pid}.tmp`;
61
+ await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, {
62
+ encoding: "utf8",
63
+ mode: 0o600,
64
+ });
65
+ await rename(temporary, file);
66
+ }
67
+ function processAlive(pid) {
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ function processCommandLine(pid) {
77
+ return new Promise((resolve) => {
78
+ const command = process.platform === "win32" ? "powershell.exe" : "ps";
79
+ const args = process.platform === "win32"
80
+ ? [
81
+ "-NoProfile",
82
+ "-Command",
83
+ `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
84
+ ]
85
+ : ["-p", String(pid), "-o", "command="];
86
+ execFile(command, args, { timeout: 2_000 }, (error, stdout) => {
87
+ resolve(error ? undefined : String(stdout));
88
+ });
89
+ });
90
+ }
91
+ async function processRecordIsOwned(record, command, tunnelId) {
92
+ if (record.command !== command ||
93
+ record.tunnelId !== tunnelId ||
94
+ !processAlive(record.pid))
95
+ return false;
96
+ const commandLine = await processCommandLine(record.pid);
97
+ return (commandLine?.includes(command) === true && commandLine.includes(tunnelId));
98
+ }
99
+ async function waitForProcessExit(pid, timeoutMs = 2_000) {
100
+ const deadline = Date.now() + timeoutMs;
101
+ while (Date.now() < deadline) {
102
+ if (!processAlive(pid))
103
+ return true;
104
+ await new Promise((resolve) => setTimeout(resolve, 50));
105
+ }
106
+ return !processAlive(pid);
107
+ }
34
108
  export function createDevTunnelRuntime(input) {
35
109
  const command = input.command ?? "devtunnel";
36
110
  const tunnelId = input.tunnelId;
37
111
  const publicBaseUrl = input.publicBaseUrl;
112
+ const processStateFile = input.processStateFile;
38
113
  const run = input.runCommand ?? defaultCommandRunner;
39
114
  let child;
40
115
  const observeLogin = async () => {
@@ -58,12 +133,26 @@ export function createDevTunnelRuntime(input) {
58
133
  ...(publicBaseUrl === undefined ? {} : { publicBaseUrl }),
59
134
  };
60
135
  };
136
+ const ownedPersistedPid = async () => {
137
+ if (!processStateFile || !tunnelId)
138
+ return undefined;
139
+ const record = await readProcessRecord(processStateFile);
140
+ if (!record)
141
+ return undefined;
142
+ if (await processRecordIsOwned(record, command, tunnelId))
143
+ return record.pid;
144
+ await rm(processStateFile, { force: true });
145
+ return undefined;
146
+ };
61
147
  return {
62
148
  command,
63
- // A live owned child is RUNNING; without one we cannot prove the
64
- // external tunnel has stopped, so we report UNKNOWN rather than a
65
- // deterministic STOPPED.
66
- status: () => observe(child ? "RUNNING" : "UNKNOWN"),
149
+ async status() {
150
+ if (await ownedPersistedPid())
151
+ return observe("RUNNING");
152
+ if (child?.pid && processAlive(child.pid))
153
+ return observe("RUNNING");
154
+ return observe("UNKNOWN");
155
+ },
67
156
  loginStatus: () => observeLogin(),
68
157
  publicBaseUrl: () => publicBaseUrl,
69
158
  async start() {
@@ -73,20 +162,24 @@ export function createDevTunnelRuntime(input) {
73
162
  if (tunnelId === undefined) {
74
163
  throw new TypeError("tunnelId is required to host the configured persistent tunnel");
75
164
  }
76
- if (child)
165
+ if (await ownedPersistedPid())
166
+ return observe("RUNNING");
167
+ if (child?.pid && processAlive(child.pid))
77
168
  return observe("RUNNING");
78
- child = spawn(command, ["host", tunnelId], { stdio: "ignore" });
169
+ const spawned = spawn(command, ["host", tunnelId], {
170
+ stdio: "ignore",
171
+ detached: true,
172
+ });
79
173
  const confirmed = await new Promise((resolve) => {
80
174
  let settled = false;
81
175
  const fail = () => {
82
176
  if (settled)
83
177
  return;
84
178
  settled = true;
85
- child = undefined;
86
179
  resolve("FAILED");
87
180
  };
88
- child?.once("error", fail);
89
- child?.once("exit", fail);
181
+ spawned.once("error", fail);
182
+ spawned.once("exit", fail);
90
183
  setTimeout(() => {
91
184
  if (settled)
92
185
  return;
@@ -94,18 +187,56 @@ export function createDevTunnelRuntime(input) {
94
187
  resolve("RUNNING");
95
188
  }, START_CONFIRM_MS);
96
189
  });
97
- if (confirmed === "FAILED") {
190
+ if (confirmed === "FAILED" || spawned.pid === undefined) {
98
191
  throw new Error("devtunnel host process failed to start");
99
192
  }
193
+ child = spawned;
194
+ spawned.once("exit", () => {
195
+ if (child === spawned)
196
+ child = undefined;
197
+ if (processStateFile)
198
+ void rm(processStateFile, { force: true });
199
+ });
200
+ if (processStateFile) {
201
+ try {
202
+ await writeProcessRecord(processStateFile, {
203
+ contract: "proflow.dev-tunnel-process.v1",
204
+ pid: spawned.pid,
205
+ command,
206
+ tunnelId,
207
+ startedAt: new Date().toISOString(),
208
+ });
209
+ }
210
+ catch (error) {
211
+ spawned.kill();
212
+ child = undefined;
213
+ throw error;
214
+ }
215
+ }
216
+ spawned.unref();
100
217
  return observe("RUNNING");
101
218
  },
102
219
  async stop() {
103
- if (child) {
104
- child.kill();
220
+ const persistedPid = await ownedPersistedPid();
221
+ const pid = persistedPid ?? child?.pid;
222
+ if (!pid || !processAlive(pid)) {
223
+ if (processStateFile)
224
+ await rm(processStateFile, { force: true });
105
225
  child = undefined;
106
- return observe("STOPPED");
226
+ return observe("UNKNOWN");
107
227
  }
108
- return observe("UNKNOWN");
228
+ try {
229
+ process.kill(pid, "SIGTERM");
230
+ }
231
+ catch {
232
+ return observe("UNKNOWN");
233
+ }
234
+ if (!(await waitForProcessExit(pid)))
235
+ return observe("UNKNOWN");
236
+ if (processStateFile)
237
+ await rm(processStateFile, { force: true });
238
+ child = undefined;
239
+ return observe("STOPPED");
109
240
  },
110
241
  async restart() {
111
242
  const stopped = await this.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomflow/proflow-dev-tunnel",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -3,7 +3,7 @@
3
3
  "contractVersion": "1.0.0",
4
4
  "moduleRef": "dev-tunnel",
5
5
  "packageName": "@tomflow/proflow-dev-tunnel",
6
- "moduleVersion": "0.1.2",
6
+ "moduleVersion": "0.1.4",
7
7
  "kind": "external-resource",
8
8
  "templateVersion": "1.0.0",
9
9
  "platformCompatibility": ">=1.0.0 <2.0.0",
@@ -36,6 +36,12 @@
36
36
  "type": "string",
37
37
  "required": false,
38
38
  "description": "Persistent Microsoft Dev Tunnel identifier used when this adapter owns lifecycle start/stop"
39
+ },
40
+ {
41
+ "key": "verificationEvidenceFile",
42
+ "type": "path",
43
+ "required": false,
44
+ "description": "JSON evidence for real file-relay and 429/5xx verification behind the configured public ingress"
39
45
  }
40
46
  ],
41
47
  "lifecycle": {