@devfn/processes 0.1.0
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/README.md +3 -0
- package/dist/adapters.d.ts +3 -0
- package/dist/adapters.js +31 -0
- package/dist/identity.d.ts +1 -0
- package/dist/identity.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/readiness.d.ts +14 -0
- package/dist/readiness.js +216 -0
- package/dist/redaction.d.ts +5 -0
- package/dist/redaction.js +22 -0
- package/dist/supervisor.d.ts +10 -0
- package/dist/supervisor.js +149 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.js +10 -0
- package/dist/wrapper.d.ts +1 -0
- package/dist/wrapper.js +38 -0
- package/package.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ProcessSpec } from "@devfn/config";
|
|
2
|
+
export declare function resolveAdapterCommand(spec: ProcessSpec): string[];
|
|
3
|
+
export declare function createProcessEnvironment(spec: ProcessSpec, generated?: Record<string, string>, source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function resolveAdapterCommand(spec) {
|
|
2
|
+
const extra = spec.command ?? [];
|
|
3
|
+
switch (spec.adapter) {
|
|
4
|
+
case "command": return [...extra];
|
|
5
|
+
case "npm": return ["npm", "run", spec.script, ...(extra.length ? ["--", ...extra] : [])];
|
|
6
|
+
case "pnpm": return ["corepack", "pnpm", "run", spec.script, ...extra];
|
|
7
|
+
case "turbo": return ["npm", "exec", "--offline", "--", "turbo", ...extra];
|
|
8
|
+
case "wrangler": return ["npm", "exec", "--offline", "--", "wrangler", ...(extra.length ? extra : ["dev"])];
|
|
9
|
+
case "xcode": return ["xcodebuild", ...extra];
|
|
10
|
+
case "extfn": return ["npm", "exec", "--offline", "--", "extfn", ...(extra.length ? extra : ["dev"])];
|
|
11
|
+
default: {
|
|
12
|
+
const unsupported = spec.adapter;
|
|
13
|
+
throw new Error(`Unsupported process adapter: ${String(unsupported)}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const BASE_ENV = ["PATH", "SHELL", "HOME", "USER", "LOGNAME", "TMPDIR", "TMP", "TEMP", "SystemRoot", "ComSpec", "PATHEXT"];
|
|
18
|
+
export function createProcessEnvironment(spec, generated = {}, source = process.env) {
|
|
19
|
+
const result = {};
|
|
20
|
+
for (const key of [...BASE_ENV, ...(spec.envAllowlist ?? [])])
|
|
21
|
+
if (source[key] !== undefined)
|
|
22
|
+
result[key] = source[key];
|
|
23
|
+
Object.assign(result, spec.env ?? {}, generated);
|
|
24
|
+
if (spec.exposure !== "public") {
|
|
25
|
+
result.HOST = "127.0.0.1";
|
|
26
|
+
result.DEVFN_HOST = "127.0.0.1";
|
|
27
|
+
}
|
|
28
|
+
if (spec.adapter === "pnpm")
|
|
29
|
+
result.COREPACK_ENABLE_NETWORK = "0";
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { matchesProcessIdentity, processBirthSignature, processExists } from "@devfn/config";
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { matchesProcessIdentity, processBirthSignature, processExists } from "@devfn/config";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createProcessEnvironment, resolveAdapterCommand } from "./adapters.js";
|
|
2
|
+
export { matchesProcessIdentity, processBirthSignature, processExists } from "./identity.js";
|
|
3
|
+
export { checkReadinessNow, waitForReadiness, type ReadinessInput } from "./readiness.js";
|
|
4
|
+
export { createStreamingRedactor, type StreamingRedactor } from "./redaction.js";
|
|
5
|
+
export { ProcessSupervisor } from "./supervisor.js";
|
|
6
|
+
export * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createProcessEnvironment, resolveAdapterCommand } from "./adapters.js";
|
|
2
|
+
export { matchesProcessIdentity, processBirthSignature, processExists } from "./identity.js";
|
|
3
|
+
export { checkReadinessNow, waitForReadiness } from "./readiness.js";
|
|
4
|
+
export { createStreamingRedactor } from "./redaction.js";
|
|
5
|
+
export { ProcessSupervisor } from "./supervisor.js";
|
|
6
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HealthCheck } from "@devfn/config";
|
|
2
|
+
export type ReadinessInput = {
|
|
3
|
+
health?: HealthCheck;
|
|
4
|
+
ports: Record<string, number>;
|
|
5
|
+
logPath: string;
|
|
6
|
+
logOffset?: number;
|
|
7
|
+
cwd: string;
|
|
8
|
+
environment: NodeJS.ProcessEnv;
|
|
9
|
+
isAlive: () => boolean | Promise<boolean>;
|
|
10
|
+
readLog?: () => Promise<string>;
|
|
11
|
+
previouslyReady?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function checkReadinessNow(input: ReadinessInput): Promise<boolean>;
|
|
14
|
+
export declare function waitForReadiness(input: ReadinessInput): Promise<void>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { ProcessError } from "./types.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
const LOG_CHUNK_BYTES = 256 * 1024;
|
|
9
|
+
const LOG_WINDOW_BYTES = 1024 * 1024;
|
|
10
|
+
async function withinDeadline(promise, remainingMs) {
|
|
11
|
+
if (remainingMs <= 0)
|
|
12
|
+
throw new Error("Readiness deadline elapsed.");
|
|
13
|
+
let timer;
|
|
14
|
+
try {
|
|
15
|
+
return await Promise.race([
|
|
16
|
+
promise,
|
|
17
|
+
new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error("Readiness operation exceeded the remaining deadline.")), remainingMs); }),
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
if (timer)
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function tcpReady(port, timeoutMs = 500) {
|
|
26
|
+
return await new Promise((resolve) => {
|
|
27
|
+
const socket = net.createConnection({ host: "127.0.0.1", port });
|
|
28
|
+
socket.setTimeout(timeoutMs);
|
|
29
|
+
const done = (value) => { socket.destroy(); resolve(value); };
|
|
30
|
+
socket.once("connect", () => done(true));
|
|
31
|
+
socket.once("timeout", () => done(false));
|
|
32
|
+
socket.once("error", () => done(false));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function readNewLog(logPath, offset) {
|
|
36
|
+
const handle = await open(logPath, "r").catch(() => undefined);
|
|
37
|
+
if (!handle)
|
|
38
|
+
return { bytes: Buffer.alloc(0), nextOffset: offset, reset: false };
|
|
39
|
+
try {
|
|
40
|
+
const size = (await handle.stat()).size;
|
|
41
|
+
if (size <= offset)
|
|
42
|
+
return { bytes: Buffer.alloc(0), nextOffset: Math.min(size, offset), reset: size < offset };
|
|
43
|
+
const length = Math.min(size - offset, LOG_CHUNK_BYTES);
|
|
44
|
+
const buffer = Buffer.alloc(length);
|
|
45
|
+
const { bytesRead } = await handle.read(buffer, 0, length, offset);
|
|
46
|
+
return { bytes: buffer.subarray(0, bytesRead), nextOffset: offset + bytesRead, reset: false };
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await handle.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function readLogWindow(logPath) {
|
|
53
|
+
const handle = await open(logPath, "r").catch(() => undefined);
|
|
54
|
+
if (!handle)
|
|
55
|
+
return "";
|
|
56
|
+
try {
|
|
57
|
+
const size = (await handle.stat()).size;
|
|
58
|
+
const length = Math.min(size, LOG_WINDOW_BYTES);
|
|
59
|
+
const buffer = Buffer.alloc(length);
|
|
60
|
+
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
|
61
|
+
return new TextDecoder().decode(buffer.subarray(0, bytesRead));
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await handle.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function resolveHttpUrl(health, input) {
|
|
68
|
+
const port = health.port ? input.ports[health.port] : undefined;
|
|
69
|
+
let url = health.url ?? `http://127.0.0.1:${port}${health.path ?? "/"}`;
|
|
70
|
+
if (health.url && health.path) {
|
|
71
|
+
const parsed = new URL(health.url);
|
|
72
|
+
const baseSearch = parsed.search;
|
|
73
|
+
const baseHash = parsed.hash;
|
|
74
|
+
parsed.search = "";
|
|
75
|
+
parsed.hash = "";
|
|
76
|
+
parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/`;
|
|
77
|
+
const appended = new URL(health.path.replace(/^\/+/, ""), parsed);
|
|
78
|
+
if (appended.origin !== parsed.origin)
|
|
79
|
+
throw new Error("HTTP readiness path must stay on the configured URL origin.");
|
|
80
|
+
if (!appended.search)
|
|
81
|
+
appended.search = baseSearch;
|
|
82
|
+
if (!appended.hash)
|
|
83
|
+
appended.hash = baseHash;
|
|
84
|
+
url = appended.toString();
|
|
85
|
+
}
|
|
86
|
+
const parsed = new URL(url);
|
|
87
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
88
|
+
throw new Error("HTTP readiness URL must use http or https.");
|
|
89
|
+
return parsed.toString();
|
|
90
|
+
}
|
|
91
|
+
async function httpReady(health, input, timeoutMs) {
|
|
92
|
+
const url = resolveHttpUrl(health, input);
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
95
|
+
try {
|
|
96
|
+
return (await fetch(url, { signal: controller.signal })).status === (health.expectedStatus ?? 200);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function checkReadinessNow(input) {
|
|
103
|
+
const defaultTimeoutMs = input.health?.type === "tcp" ? 500 : input.health?.type === "command" ? 2000 : 1000;
|
|
104
|
+
const timeoutMs = Math.max(1, input.health?.timeoutMs ?? defaultTimeoutMs);
|
|
105
|
+
const deadline = Date.now() + timeoutMs;
|
|
106
|
+
try {
|
|
107
|
+
if (!await withinDeadline(Promise.resolve(input.isAlive()), deadline - Date.now()))
|
|
108
|
+
return false;
|
|
109
|
+
if (!input.health)
|
|
110
|
+
return true;
|
|
111
|
+
if ((input.health.type === "tcp" || input.health.type === "http") && input.health.port && input.ports[input.health.port] === undefined)
|
|
112
|
+
return false;
|
|
113
|
+
let ready;
|
|
114
|
+
if (input.health.type === "log") {
|
|
115
|
+
if (input.previouslyReady)
|
|
116
|
+
ready = true;
|
|
117
|
+
else {
|
|
118
|
+
const output = input.readLog
|
|
119
|
+
? await withinDeadline(input.readLog(), deadline - Date.now())
|
|
120
|
+
: await withinDeadline(readLogWindow(input.logPath), deadline - Date.now());
|
|
121
|
+
ready = new RegExp(input.health.pattern, "i").test(output.slice(-LOG_WINDOW_BYTES));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
ready = await withinDeadline(checkReadiness(input.health, input, deadline - Date.now()), deadline - Date.now());
|
|
126
|
+
if (!ready || Date.now() >= deadline)
|
|
127
|
+
return false;
|
|
128
|
+
return await withinDeadline(Promise.resolve(input.isAlive()), deadline - Date.now());
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function checkReadiness(health, input, remainingMs) {
|
|
135
|
+
switch (health.type) {
|
|
136
|
+
case "tcp": return await tcpReady(input.ports[health.port], Math.max(1, Math.min(health.timeoutMs ?? 500, remainingMs)));
|
|
137
|
+
case "http": return await httpReady(health, input, Math.max(1, Math.min(health.timeoutMs ?? 1000, remainingMs)));
|
|
138
|
+
case "command": {
|
|
139
|
+
const [file, ...args] = health.command;
|
|
140
|
+
await execFileAsync(file, args, { cwd: input.cwd, env: input.environment, timeout: Math.max(1, Math.min(health.timeoutMs ?? 2000, remainingMs)) });
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function waitForReadiness(input) {
|
|
146
|
+
if (!input.health) {
|
|
147
|
+
await delay(500);
|
|
148
|
+
if (!await withinDeadline(Promise.resolve(input.isAlive()), 1000))
|
|
149
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", "Process exited before startup completed.");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const timeoutMs = input.health.timeoutMs ?? 120_000;
|
|
153
|
+
const deadline = Date.now() + timeoutMs;
|
|
154
|
+
let lastError;
|
|
155
|
+
let logPattern;
|
|
156
|
+
let logOffset = input.logOffset ?? 0;
|
|
157
|
+
let logWindow = "";
|
|
158
|
+
let logDecoder = new TextDecoder();
|
|
159
|
+
if (input.health.type === "log") {
|
|
160
|
+
try {
|
|
161
|
+
logPattern = new RegExp(input.health.pattern, "i");
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw new ProcessError("DEVFN_PROCESS_NOT_READY", `Invalid readiness pattern ${input.health.pattern}.`, { cause: String(error) });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if ((input.health.type === "tcp" || input.health.type === "http") && input.health.port && input.ports[input.health.port] === undefined) {
|
|
168
|
+
throw new ProcessError("DEVFN_PROCESS_NOT_READY", `Readiness check references unallocated port ${input.health.port}.`);
|
|
169
|
+
}
|
|
170
|
+
if (input.health.type === "http") {
|
|
171
|
+
try {
|
|
172
|
+
resolveHttpUrl(input.health, input);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw new ProcessError("DEVFN_PROCESS_NOT_READY", "Invalid HTTP readiness configuration.", { cause: error instanceof Error ? error.message : String(error) });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
while (Date.now() < deadline) {
|
|
179
|
+
try {
|
|
180
|
+
if (!await withinDeadline(Promise.resolve(input.isAlive()), deadline - Date.now()))
|
|
181
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", "Process exited while waiting for readiness.");
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error instanceof ProcessError)
|
|
185
|
+
throw error;
|
|
186
|
+
lastError = error;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
if (Date.now() >= deadline)
|
|
190
|
+
break;
|
|
191
|
+
try {
|
|
192
|
+
if (input.health.type === "log") {
|
|
193
|
+
if (input.readLog)
|
|
194
|
+
logWindow = (await withinDeadline(input.readLog(), deadline - Date.now())).slice(-LOG_WINDOW_BYTES);
|
|
195
|
+
else {
|
|
196
|
+
const chunk = await readNewLog(input.logPath, logOffset);
|
|
197
|
+
logOffset = chunk.nextOffset;
|
|
198
|
+
if (chunk.reset) {
|
|
199
|
+
logDecoder = new TextDecoder();
|
|
200
|
+
logWindow = "";
|
|
201
|
+
}
|
|
202
|
+
logWindow = `${logWindow}${logDecoder.decode(chunk.bytes, { stream: true })}`.slice(-LOG_WINDOW_BYTES);
|
|
203
|
+
}
|
|
204
|
+
if (logPattern?.test(logWindow) && Date.now() < deadline)
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
else if (await checkReadiness(input.health, input, Math.max(1, deadline - Date.now())) && Date.now() < deadline)
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
lastError = error;
|
|
212
|
+
}
|
|
213
|
+
await delay(Math.min(100, Math.max(1, deadline - Date.now())));
|
|
214
|
+
}
|
|
215
|
+
throw new ProcessError("DEVFN_PROCESS_NOT_READY", `Readiness check timed out after ${timeoutMs} ms.`, { cause: lastError instanceof Error ? lastError.message : String(lastError ?? "not ready") });
|
|
216
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function createStreamingRedactor(values, write) {
|
|
2
|
+
const secrets = [...new Set(values)].filter(Boolean).sort((a, b) => b.length - a.length);
|
|
3
|
+
let buffer = "";
|
|
4
|
+
const longest = Math.max(1, ...secrets.map((secret) => secret.length));
|
|
5
|
+
const flush = (ending) => {
|
|
6
|
+
while (buffer.length >= (ending ? 1 : longest)) {
|
|
7
|
+
const match = secrets.find((secret) => buffer.startsWith(secret));
|
|
8
|
+
if (match) {
|
|
9
|
+
write("[REDACTED]");
|
|
10
|
+
buffer = buffer.slice(match.length);
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
write(buffer[0]);
|
|
14
|
+
buffer = buffer.slice(1);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
push(chunk) { buffer += chunk; flush(false); },
|
|
20
|
+
end() { flush(true); },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ManagedProcess, type StartProcessInput } from "./types.js";
|
|
2
|
+
export declare function prepareProcessLog(logPath: string, resetSensitiveHistory: boolean): Promise<{
|
|
3
|
+
logFd: number;
|
|
4
|
+
logOffset: number;
|
|
5
|
+
}>;
|
|
6
|
+
export declare class ProcessSupervisor {
|
|
7
|
+
start(input: StartProcessInput): Promise<ManagedProcess>;
|
|
8
|
+
stop(managed: ManagedProcess, timeoutMs?: number): Promise<void>;
|
|
9
|
+
status(managed: ManagedProcess): Promise<"running" | "stopped" | "identity-mismatch">;
|
|
10
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, constants, fchmodSync, mkdirSync, openSync } from "node:fs";
|
|
3
|
+
import { lstat, mkdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { resolveContainedPath } from "@devfn/config";
|
|
7
|
+
import { createProcessEnvironment, resolveAdapterCommand } from "./adapters.js";
|
|
8
|
+
import { matchesProcessIdentity, processBirthSignature, processExists } from "./identity.js";
|
|
9
|
+
import { waitForReadiness } from "./readiness.js";
|
|
10
|
+
import { ProcessError } from "./types.js";
|
|
11
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
async function terminateProcess(pid, force = false) {
|
|
13
|
+
if (process.platform !== "win32") {
|
|
14
|
+
process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
await new Promise((resolve, reject) => {
|
|
18
|
+
const child = spawn("taskkill", ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])], { stdio: "ignore", windowsHide: true });
|
|
19
|
+
child.once("error", reject);
|
|
20
|
+
child.once("exit", (code) => code === 0 || !processExists(pid) ? resolve() : reject(new Error(`taskkill exited with ${code}`)));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
24
|
+
const deadline = Date.now() + timeoutMs;
|
|
25
|
+
while (Date.now() < deadline && processExists(pid))
|
|
26
|
+
await delay(50);
|
|
27
|
+
return !processExists(pid);
|
|
28
|
+
}
|
|
29
|
+
export async function prepareProcessLog(logPath, resetSensitiveHistory) {
|
|
30
|
+
const existing = await lstat(logPath).catch((error) => {
|
|
31
|
+
if (error.code === "ENOENT")
|
|
32
|
+
return undefined;
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
if (existing?.isSymbolicLink())
|
|
36
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Refusing symlinked process log ${logPath}.`);
|
|
37
|
+
const logOffset = resetSensitiveHistory ? 0 : existing?.size ?? 0;
|
|
38
|
+
const flags = constants.O_WRONLY | constants.O_CREAT | (resetSensitiveHistory ? constants.O_TRUNC : constants.O_APPEND) | (constants.O_NOFOLLOW ?? 0);
|
|
39
|
+
let logFd;
|
|
40
|
+
try {
|
|
41
|
+
logFd = openSync(logPath, flags, 0o600);
|
|
42
|
+
fchmodSync(logFd, 0o600);
|
|
43
|
+
return { logFd, logOffset };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (logFd !== undefined)
|
|
47
|
+
closeSync(logFd);
|
|
48
|
+
if (error instanceof ProcessError)
|
|
49
|
+
throw error;
|
|
50
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Unable to open process log ${logPath} safely.`, { cause: error instanceof Error ? error.message : String(error) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class ProcessSupervisor {
|
|
54
|
+
async start(input) {
|
|
55
|
+
const command = resolveAdapterCommand(input.spec);
|
|
56
|
+
if (command.length === 0)
|
|
57
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Process ${input.name} has no command.`);
|
|
58
|
+
const cwd = await resolveContainedPath(input.root, input.spec.cwd ?? ".", `processes.${input.name}.cwd`).catch((error) => {
|
|
59
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Process cwd escapes repository: ${input.spec.cwd ?? "."}`, { cause: error instanceof Error ? error.message : String(error) });
|
|
60
|
+
});
|
|
61
|
+
const logsDir = path.join(input.runtimeDir, "logs");
|
|
62
|
+
await mkdir(logsDir, { recursive: true, mode: 0o700 });
|
|
63
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(input.name) || input.name !== input.name.trim())
|
|
64
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Invalid process name ${input.name}.`);
|
|
65
|
+
const logPath = path.join(logsDir, `${input.name}.log`);
|
|
66
|
+
mkdirSync(path.dirname(logPath), { recursive: true });
|
|
67
|
+
const { logFd, logOffset } = await prepareProcessLog(logPath, Boolean(input.spec.secretEnv?.length));
|
|
68
|
+
const environment = createProcessEnvironment(input.spec, input.environment);
|
|
69
|
+
const wrapperPath = fileURLToPath(new URL("./wrapper.js", import.meta.url));
|
|
70
|
+
const child = spawn(process.execPath, [wrapperPath], {
|
|
71
|
+
cwd,
|
|
72
|
+
env: { ...environment, DEVFN_WRAPPED_COMMAND: JSON.stringify(command), DEVFN_REDACT_KEYS: JSON.stringify(input.spec.secretEnv ?? []) },
|
|
73
|
+
detached: process.platform !== "win32",
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
stdio: ["ignore", logFd, logFd],
|
|
76
|
+
});
|
|
77
|
+
let exited = false;
|
|
78
|
+
child.once("exit", () => { exited = true; });
|
|
79
|
+
closeSync(logFd);
|
|
80
|
+
await new Promise((resolve, reject) => {
|
|
81
|
+
const timer = setTimeout(resolve, 50);
|
|
82
|
+
child.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
83
|
+
child.once("spawn", () => { clearTimeout(timer); resolve(); });
|
|
84
|
+
}).catch((error) => { throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Unable to start ${input.name}.`, { cause: error instanceof Error ? error.message : String(error) }); });
|
|
85
|
+
if (!child.pid)
|
|
86
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Process ${input.name} did not receive a PID.`);
|
|
87
|
+
child.unref();
|
|
88
|
+
let birthSignature;
|
|
89
|
+
for (let attempt = 0; attempt < 10 && !birthSignature; attempt += 1) {
|
|
90
|
+
birthSignature = await processBirthSignature(child.pid);
|
|
91
|
+
if (!birthSignature)
|
|
92
|
+
await delay(20);
|
|
93
|
+
}
|
|
94
|
+
if (!birthSignature) {
|
|
95
|
+
try {
|
|
96
|
+
if (process.platform !== "win32")
|
|
97
|
+
process.kill(-child.pid, "SIGTERM");
|
|
98
|
+
else
|
|
99
|
+
child.kill();
|
|
100
|
+
}
|
|
101
|
+
catch { /* process may already have exited */ }
|
|
102
|
+
throw new ProcessError("DEVFN_PROCESS_START_FAILED", `Could not establish a birth identity for ${input.name}; refusing unmanaged supervision.`);
|
|
103
|
+
}
|
|
104
|
+
const managed = {
|
|
105
|
+
name: input.name,
|
|
106
|
+
pid: child.pid,
|
|
107
|
+
birthSignature,
|
|
108
|
+
command,
|
|
109
|
+
cwd,
|
|
110
|
+
logPath,
|
|
111
|
+
startedAt: new Date().toISOString(),
|
|
112
|
+
...(input.spec.shutdownTimeoutMs === undefined ? {} : { shutdownTimeoutMs: input.spec.shutdownTimeoutMs }),
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
await input.onStarted?.(managed);
|
|
116
|
+
await waitForReadiness({ health: input.spec.health, ports: input.ports ?? {}, logPath, logOffset, cwd, environment, isAlive: () => !exited && processExists(managed.pid) });
|
|
117
|
+
managed.readyAt = new Date().toISOString();
|
|
118
|
+
return managed;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
await this.stop(managed, input.spec.shutdownTimeoutMs).catch(() => undefined);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async stop(managed, timeoutMs = managed.shutdownTimeoutMs ?? 10_000) {
|
|
126
|
+
if (!processExists(managed.pid))
|
|
127
|
+
return;
|
|
128
|
+
if (!await matchesProcessIdentity(managed.pid, managed.birthSignature)) {
|
|
129
|
+
throw new ProcessError("DEVFN_PROCESS_OWNERSHIP_MISMATCH", `PID ${managed.pid} no longer matches the DevFn process identity.`, { name: managed.name, pid: managed.pid });
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await terminateProcess(managed.pid);
|
|
133
|
+
if (!await waitForProcessExit(managed.pid, timeoutMs)) {
|
|
134
|
+
await terminateProcess(managed.pid, true);
|
|
135
|
+
if (!await waitForProcessExit(managed.pid, 5_000))
|
|
136
|
+
throw new ProcessError("DEVFN_PROCESS_STOP_FAILED", `Process ${managed.name} did not exit after forced termination.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error.code !== "ESRCH")
|
|
141
|
+
throw new ProcessError("DEVFN_PROCESS_STOP_FAILED", `Unable to stop ${managed.name}.`, { cause: error instanceof Error ? error.message : String(error) });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async status(managed) {
|
|
145
|
+
if (!processExists(managed.pid))
|
|
146
|
+
return "stopped";
|
|
147
|
+
return await matchesProcessIdentity(managed.pid, managed.birthSignature) ? "running" : "identity-mismatch";
|
|
148
|
+
}
|
|
149
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ProcessSpec } from "@devfn/config";
|
|
2
|
+
export interface ManagedProcess {
|
|
3
|
+
name: string;
|
|
4
|
+
pid: number;
|
|
5
|
+
birthSignature?: string;
|
|
6
|
+
command: string[];
|
|
7
|
+
cwd: string;
|
|
8
|
+
logPath: string;
|
|
9
|
+
startedAt: string;
|
|
10
|
+
readyAt?: string;
|
|
11
|
+
shutdownTimeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface StartProcessInput {
|
|
14
|
+
name: string;
|
|
15
|
+
spec: ProcessSpec;
|
|
16
|
+
root: string;
|
|
17
|
+
runtimeDir: string;
|
|
18
|
+
environment?: Record<string, string>;
|
|
19
|
+
ports?: Record<string, number>;
|
|
20
|
+
onStarted?: (process: ManagedProcess) => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare class ProcessError extends Error {
|
|
23
|
+
readonly code: "DEVFN_PROCESS_START_FAILED" | "DEVFN_PROCESS_NOT_READY" | "DEVFN_PROCESS_OWNERSHIP_MISMATCH" | "DEVFN_PROCESS_STOP_FAILED";
|
|
24
|
+
readonly details?: Record<string, unknown> | undefined;
|
|
25
|
+
constructor(code: "DEVFN_PROCESS_START_FAILED" | "DEVFN_PROCESS_NOT_READY" | "DEVFN_PROCESS_OWNERSHIP_MISMATCH" | "DEVFN_PROCESS_STOP_FAILED", message: string, details?: Record<string, unknown> | undefined);
|
|
26
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/wrapper.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createStreamingRedactor } from "./redaction.js";
|
|
3
|
+
function requiredJson(name) {
|
|
4
|
+
const raw = process.env[name];
|
|
5
|
+
if (!raw)
|
|
6
|
+
throw new Error(`Missing ${name}.`);
|
|
7
|
+
return JSON.parse(raw);
|
|
8
|
+
}
|
|
9
|
+
const command = requiredJson("DEVFN_WRAPPED_COMMAND");
|
|
10
|
+
const keys = requiredJson("DEVFN_REDACT_KEYS");
|
|
11
|
+
if (!Array.isArray(command) || command.length === 0 || !command.every((item) => typeof item === "string"))
|
|
12
|
+
throw new Error("Invalid wrapped command.");
|
|
13
|
+
if (!Array.isArray(keys) || !keys.every((item) => typeof item === "string"))
|
|
14
|
+
throw new Error("Invalid redaction keys.");
|
|
15
|
+
const environment = { ...process.env };
|
|
16
|
+
delete environment.DEVFN_WRAPPED_COMMAND;
|
|
17
|
+
delete environment.DEVFN_REDACT_KEYS;
|
|
18
|
+
const secrets = [...new Set(keys.map((key) => environment[key]).filter((value) => typeof value === "string" && value.length > 0))].sort((a, b) => b.length - a.length);
|
|
19
|
+
const child = spawn(command[0], command.slice(1), { cwd: process.cwd(), env: environment, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
20
|
+
function pipeRedacted(stream, destination) {
|
|
21
|
+
if (secrets.length === 0) {
|
|
22
|
+
stream.pipe(destination, { end: false });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const redactor = createStreamingRedactor(secrets, (value) => destination.write(value));
|
|
26
|
+
stream.setEncoding("utf8");
|
|
27
|
+
stream.on("data", (chunk) => redactor.push(chunk));
|
|
28
|
+
stream.on("end", () => redactor.end());
|
|
29
|
+
}
|
|
30
|
+
pipeRedacted(child.stdout, process.stdout);
|
|
31
|
+
pipeRedacted(child.stderr, process.stderr);
|
|
32
|
+
child.once("error", (error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
|
|
33
|
+
child.once("close", (code, signal) => {
|
|
34
|
+
if (signal)
|
|
35
|
+
process.kill(process.pid, signal);
|
|
36
|
+
else
|
|
37
|
+
process.exitCode = code ?? 1;
|
|
38
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devfn/processes",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Browser-neutral native process supervision and readiness for DevFn and ProbeFn consumers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
9
|
+
"files": ["dist", "README.md"],
|
|
10
|
+
"scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest run", "lint": "echo 'lint not configured'", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"" },
|
|
11
|
+
"dependencies": { "@devfn/config": "0.1.0" },
|
|
12
|
+
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0", "vitest": "^3.2.4" },
|
|
13
|
+
"engines": { "node": ">=22" },
|
|
14
|
+
"license": "MIT"
|
|
15
|
+
}
|