@tomflow/proflow-dev-tunnel 0.1.3 → 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,11 @@
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
+
3
9
  ## 0.1.3
4
10
 
5
11
  ### Patch Changes
@@ -134,6 +134,7 @@ export declare const behaviorAdapter: {
134
134
  export declare function createProductionBinding(input: {
135
135
  moduleRef: string;
136
136
  config: Record<string, string>;
137
+ workspaceRoot: string;
137
138
  }): Promise<{
138
139
  behaviorAdapter: Record<string, unknown>;
139
140
  } | undefined>;
@@ -1,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
2
3
  import { verifyPublicIngress } from "../src/resource-adapter.js";
3
4
  import { descriptor } from "./descriptor.js";
4
5
  const success = (data) => ({
@@ -290,6 +291,7 @@ export async function createProductionBinding(input) {
290
291
  runtime: createDevTunnelRuntime({
291
292
  publicBaseUrl,
292
293
  ...(input.config.tunnelId ? { tunnelId: input.config.tunnelId } : {}),
294
+ processStateFile: join(input.workspaceRoot, ".proflow", "runtime", "external-resources", "dev-tunnel", "process.json"),
293
295
  }),
294
296
  verifyFileRelay: async () => (await readEvidence())?.fileRelay ?? {
295
297
  verified: false,
@@ -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.3";
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";
@@ -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.3",
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",
@@ -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.3",
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.3",
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",