@rigkit/runtime-client 0.0.0-canary-20260518T014918-c5bc0c2
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/package.json +35 -0
- package/src/api.ts +242 -0
- package/src/client.ts +261 -0
- package/src/errors.ts +124 -0
- package/src/http.ts +96 -0
- package/src/index.ts +105 -0
- package/src/manager.test.ts +347 -0
- package/src/manager.ts +643 -0
- package/src/schemas.ts +97 -0
- package/src/session.ts +140 -0
- package/src/version.ts +1 -0
package/src/manager.ts
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import type { Readable } from "node:stream";
|
|
7
|
+
import { Cause, Effect, Exit } from "effect";
|
|
8
|
+
import {
|
|
9
|
+
RuntimeApiVersionError,
|
|
10
|
+
RuntimeConnectionError,
|
|
11
|
+
RuntimeStartupError,
|
|
12
|
+
isRuntimeClientError,
|
|
13
|
+
type RuntimeClientError,
|
|
14
|
+
} from "./errors.ts";
|
|
15
|
+
import {
|
|
16
|
+
createRuntimeHttpClient,
|
|
17
|
+
type RuntimeHttpClient,
|
|
18
|
+
} from "./client.ts";
|
|
19
|
+
import {
|
|
20
|
+
SUPPORTED_RUNTIME_API_VERSION,
|
|
21
|
+
runtimeStreamEffect,
|
|
22
|
+
} from "./http.ts";
|
|
23
|
+
import {
|
|
24
|
+
runtimeSessionEffect,
|
|
25
|
+
type RuntimeSessionHandlers,
|
|
26
|
+
} from "./session.ts";
|
|
27
|
+
import {
|
|
28
|
+
RuntimeHandleSchema,
|
|
29
|
+
RuntimeReadySchema,
|
|
30
|
+
type RuntimeHandle,
|
|
31
|
+
} from "./schemas.ts";
|
|
32
|
+
|
|
33
|
+
export type RuntimeProjectOptions = {
|
|
34
|
+
projectDir: string;
|
|
35
|
+
configPath: string;
|
|
36
|
+
statePath?: string;
|
|
37
|
+
globalFragmentRoot?: string;
|
|
38
|
+
source?: unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type RuntimeClient = {
|
|
42
|
+
handle: RuntimeHandle;
|
|
43
|
+
paths: RuntimePaths;
|
|
44
|
+
token: string;
|
|
45
|
+
control: RuntimeHttpClient;
|
|
46
|
+
runEvents(
|
|
47
|
+
runId: string,
|
|
48
|
+
onEvent: (event: unknown) => Promise<void> | void,
|
|
49
|
+
): Promise<void>;
|
|
50
|
+
runSession(
|
|
51
|
+
runId: string,
|
|
52
|
+
handlers: RuntimeSessionHandlers,
|
|
53
|
+
): Promise<void>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type RemoteRuntimeOptions = {
|
|
57
|
+
url: string;
|
|
58
|
+
token: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type GetOrStartRuntimeOptions = RuntimeProjectOptions & {
|
|
62
|
+
rigkitHome?: string;
|
|
63
|
+
idleMs?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type RuntimePaths = {
|
|
67
|
+
root: string;
|
|
68
|
+
handlePath: string;
|
|
69
|
+
tokenPath: string;
|
|
70
|
+
lockPath: string;
|
|
71
|
+
// Where the daemon's stderr is captured. The CLI tails this on run failure
|
|
72
|
+
// so users see real stack traces instead of "Internal server error".
|
|
73
|
+
runtimeLogPath: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const DEFAULT_IDLE_MS = 30 * 60 * 1000;
|
|
77
|
+
export { SUPPORTED_RUNTIME_API_VERSION };
|
|
78
|
+
|
|
79
|
+
export async function getOrStartRuntime(options: GetOrStartRuntimeOptions): Promise<RuntimeClient> {
|
|
80
|
+
return runRuntimeClientEffect(getOrStartRuntimeEffect(options));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getOrStartRuntimeEffect(options: GetOrStartRuntimeOptions): Effect.Effect<RuntimeClient, RuntimeClientError> {
|
|
84
|
+
return Effect.tryPromise({
|
|
85
|
+
try: () => getOrStartRuntimeUnsafe(options),
|
|
86
|
+
catch: toRuntimeClientError,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function getOrStartRuntimeUnsafe(options: GetOrStartRuntimeOptions): Promise<RuntimeClient> {
|
|
91
|
+
const projectDir = resolve(options.projectDir);
|
|
92
|
+
const configPath = resolve(options.configPath);
|
|
93
|
+
const statePath = options.statePath ? resolve(options.statePath) : undefined;
|
|
94
|
+
const globalFragmentRoot = options.globalFragmentRoot
|
|
95
|
+
? resolve(options.globalFragmentRoot)
|
|
96
|
+
: join(options.rigkitHome ?? defaultRigkitHome(), "fragments");
|
|
97
|
+
const projectId = projectIdFor({
|
|
98
|
+
projectDir,
|
|
99
|
+
configPath,
|
|
100
|
+
statePath,
|
|
101
|
+
source: options.source,
|
|
102
|
+
});
|
|
103
|
+
const runtimeFingerprint = runtimeFingerprintFor({
|
|
104
|
+
projectDir,
|
|
105
|
+
configPath,
|
|
106
|
+
statePath,
|
|
107
|
+
...(options.globalFragmentRoot ? { globalFragmentRoot } : {}),
|
|
108
|
+
source: options.source,
|
|
109
|
+
});
|
|
110
|
+
const paths = runtimePaths(projectId, options.rigkitHome);
|
|
111
|
+
|
|
112
|
+
const existing = await tryExistingRuntime(paths, projectId, runtimeFingerprint);
|
|
113
|
+
if (existing) return existing;
|
|
114
|
+
|
|
115
|
+
await withRuntimeLock(paths.lockPath, async () => {
|
|
116
|
+
const secondCheck = await tryExistingRuntime(paths, projectId, runtimeFingerprint);
|
|
117
|
+
if (secondCheck) return;
|
|
118
|
+
await startRuntime({
|
|
119
|
+
...options,
|
|
120
|
+
projectDir,
|
|
121
|
+
configPath,
|
|
122
|
+
statePath,
|
|
123
|
+
globalFragmentRoot,
|
|
124
|
+
projectId,
|
|
125
|
+
runtimeFingerprint,
|
|
126
|
+
paths,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const started = await tryExistingRuntime(paths, projectId, runtimeFingerprint);
|
|
131
|
+
if (!started) {
|
|
132
|
+
throw new RuntimeStartupError({
|
|
133
|
+
reason: "unhealthy-after-start",
|
|
134
|
+
projectDir,
|
|
135
|
+
message: `Rigkit runtime did not become healthy for ${projectDir}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return started;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function projectIdFor(options: RuntimeProjectOptions): string {
|
|
142
|
+
const configPath = resolve(options.configPath);
|
|
143
|
+
const hash = createHash("sha256");
|
|
144
|
+
hash.update(JSON.stringify({
|
|
145
|
+
projectDir: resolve(options.projectDir),
|
|
146
|
+
configPath,
|
|
147
|
+
statePath: options.statePath ? resolve(options.statePath) : null,
|
|
148
|
+
source: options.source ?? null,
|
|
149
|
+
}));
|
|
150
|
+
return `sha256-${hash.digest("hex").slice(0, 32)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function runtimeFingerprintFor(options: RuntimeProjectOptions): string {
|
|
154
|
+
const projectDir = resolve(options.projectDir);
|
|
155
|
+
const configPath = resolve(options.configPath);
|
|
156
|
+
const statePath = options.statePath ? resolve(options.statePath) : null;
|
|
157
|
+
const globalFragmentRoot = options.globalFragmentRoot ? resolve(options.globalFragmentRoot) : null;
|
|
158
|
+
const hash = createHash("sha256");
|
|
159
|
+
|
|
160
|
+
hash.update("project\0");
|
|
161
|
+
hash.update(projectDir);
|
|
162
|
+
hash.update("\0config\0");
|
|
163
|
+
hash.update(configPath);
|
|
164
|
+
hash.update("\0state\0");
|
|
165
|
+
hash.update(statePath ?? "");
|
|
166
|
+
hash.update("\0global-fragment-root\0");
|
|
167
|
+
hash.update(globalFragmentRoot ?? "");
|
|
168
|
+
hash.update("\0source\0");
|
|
169
|
+
hash.update(JSON.stringify(options.source ?? null));
|
|
170
|
+
|
|
171
|
+
updateFileFingerprint(hash, "config", configPath);
|
|
172
|
+
for (const file of dotenvFilesFor(projectDir)) updateFileFingerprint(hash, "dotenv", file);
|
|
173
|
+
for (const file of projectFingerprintFiles(projectDir)) updateFileFingerprint(hash, "project-file", file);
|
|
174
|
+
updateProjectSurfaceFingerprint(hash, projectDir);
|
|
175
|
+
updateRigkitPackageFingerprint(hash, join(projectDir, "node_modules", "@rigkit"));
|
|
176
|
+
|
|
177
|
+
return `sha256-${hash.digest("hex")}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function runtimePaths(projectId: string, rigkitHome = defaultRigkitHome()): RuntimePaths {
|
|
181
|
+
const root = join(rigkitHome, "runtimes");
|
|
182
|
+
return {
|
|
183
|
+
root,
|
|
184
|
+
handlePath: join(root, `${projectId}.json`),
|
|
185
|
+
tokenPath: join(root, `${projectId}.token`),
|
|
186
|
+
lockPath: join(root, `${projectId}.lock`),
|
|
187
|
+
runtimeLogPath: join(root, `${projectId}.log`),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function defaultRigkitHome(): string {
|
|
192
|
+
return process.env.RIGKIT_HOME ? resolve(process.env.RIGKIT_HOME) : join(homedir(), ".rigkit");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function tryExistingRuntime(
|
|
196
|
+
paths: RuntimePaths,
|
|
197
|
+
projectId: string,
|
|
198
|
+
runtimeFingerprint: string,
|
|
199
|
+
): Promise<RuntimeClient | undefined> {
|
|
200
|
+
const handle = readHandle(paths.handlePath);
|
|
201
|
+
if (!handle || handle.projectId !== projectId) return undefined;
|
|
202
|
+
const token = readToken(handle.tokenPath);
|
|
203
|
+
if (!token) {
|
|
204
|
+
removeStale(paths);
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (handle.runtimeFingerprint !== runtimeFingerprint) {
|
|
209
|
+
await shutdownRuntime(handle, token);
|
|
210
|
+
removeStale(paths);
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const body = await createRuntimeHttpClient({ baseUrl: handle.url, token }).health();
|
|
216
|
+
if (body.projectId !== projectId) {
|
|
217
|
+
throw new RuntimeConnectionError({
|
|
218
|
+
method: "GET",
|
|
219
|
+
path: "/health",
|
|
220
|
+
message: `runtime project mismatch`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (body.runtimeFingerprint !== runtimeFingerprint) {
|
|
224
|
+
throw new RuntimeConnectionError({
|
|
225
|
+
method: "GET",
|
|
226
|
+
path: "/health",
|
|
227
|
+
message: `runtime fingerprint mismatch`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return createClient(handle, paths, token);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error instanceof RuntimeApiVersionError) throw error;
|
|
233
|
+
removeStale(paths);
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function startRuntime(input: GetOrStartRuntimeOptions & {
|
|
239
|
+
projectId: string;
|
|
240
|
+
runtimeFingerprint: string;
|
|
241
|
+
paths: RuntimePaths;
|
|
242
|
+
}): Promise<void> {
|
|
243
|
+
mkdirSync(input.paths.root, { recursive: true });
|
|
244
|
+
const token = readToken(input.paths.tokenPath) ?? createToken(input.paths.tokenPath);
|
|
245
|
+
const runtimeBin = resolveRuntimeBin(input.projectDir);
|
|
246
|
+
const args = [
|
|
247
|
+
"serve",
|
|
248
|
+
"--project-id",
|
|
249
|
+
input.projectId,
|
|
250
|
+
"--runtime-fingerprint",
|
|
251
|
+
input.runtimeFingerprint,
|
|
252
|
+
"--project-dir",
|
|
253
|
+
input.projectDir,
|
|
254
|
+
"--config",
|
|
255
|
+
input.configPath,
|
|
256
|
+
"--handle",
|
|
257
|
+
input.paths.handlePath,
|
|
258
|
+
"--token",
|
|
259
|
+
input.paths.tokenPath,
|
|
260
|
+
"--idle-ms",
|
|
261
|
+
String(input.idleMs ?? DEFAULT_IDLE_MS),
|
|
262
|
+
];
|
|
263
|
+
if (input.statePath) args.push("--state", input.statePath);
|
|
264
|
+
if (input.globalFragmentRoot) args.push("--global-fragment-root", input.globalFragmentRoot);
|
|
265
|
+
if (input.source !== undefined) args.push("--source-json", JSON.stringify(input.source));
|
|
266
|
+
|
|
267
|
+
mkdirSync(input.paths.root, { recursive: true });
|
|
268
|
+
const stderrFd = openSync(input.paths.runtimeLogPath, "a");
|
|
269
|
+
const proc = spawn(runtimeBin, args, {
|
|
270
|
+
detached: true,
|
|
271
|
+
stdio: ["ignore", "pipe", stderrFd],
|
|
272
|
+
env: process.env,
|
|
273
|
+
}) as ChildProcessByStdio<null, Readable, null>;
|
|
274
|
+
// The child inherits the fd; the parent can release its own handle.
|
|
275
|
+
try {
|
|
276
|
+
closeSync(stderrFd);
|
|
277
|
+
} catch {
|
|
278
|
+
// best-effort
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const line = await readReadyLine(proc, input.paths, input.projectDir);
|
|
282
|
+
let ready: ReturnType<typeof RuntimeReadySchema.parse>;
|
|
283
|
+
try {
|
|
284
|
+
ready = RuntimeReadySchema.parse(JSON.parse(line));
|
|
285
|
+
} catch (cause) {
|
|
286
|
+
throw new RuntimeStartupError({
|
|
287
|
+
reason: "invalid-ready-output",
|
|
288
|
+
projectDir: input.projectDir,
|
|
289
|
+
message: `Rigkit runtime printed invalid ready output`,
|
|
290
|
+
cause,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (ready.token && ready.token !== token) {
|
|
294
|
+
writeFileSync(input.paths.tokenPath, `${ready.token}\n`);
|
|
295
|
+
}
|
|
296
|
+
proc.stdout.destroy();
|
|
297
|
+
proc.unref();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function createClient(handle: RuntimeHandle, paths: RuntimePaths, token: string): RuntimeClient {
|
|
301
|
+
return {
|
|
302
|
+
handle,
|
|
303
|
+
paths,
|
|
304
|
+
token,
|
|
305
|
+
control: createRuntimeHttpClient({ baseUrl: handle.url, token }),
|
|
306
|
+
runEvents: (runId, onEvent) =>
|
|
307
|
+
runRuntimeClientEffect(runtimeStreamEffect(handle.url, token, runEventsPath(runId), onEvent)),
|
|
308
|
+
runSession: (runId, handlers) =>
|
|
309
|
+
runRuntimeClientEffect(runtimeSessionEffect(handle.url, token, runSessionPath(runId), handlers)),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function connectRemoteRuntime(
|
|
314
|
+
options: RemoteRuntimeOptions,
|
|
315
|
+
): Pick<RuntimeClient, "token" | "control" | "runEvents" | "runSession"> {
|
|
316
|
+
const url = options.url.replace(/\/+$/, "");
|
|
317
|
+
const token = options.token;
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
token,
|
|
321
|
+
control: createRuntimeHttpClient({ baseUrl: url, token }),
|
|
322
|
+
runEvents: (runId, onEvent) =>
|
|
323
|
+
runRuntimeClientEffect(runtimeStreamEffect(url, token, runEventsPath(runId), onEvent)),
|
|
324
|
+
runSession: (runId, handlers) =>
|
|
325
|
+
runRuntimeClientEffect(runtimeSessionEffect(url, token, runSessionPath(runId), handlers)),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function runEventsPath(runId: string): string {
|
|
330
|
+
return `/runs/${encodeURIComponent(runId)}/events`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function runSessionPath(runId: string): string {
|
|
334
|
+
return `/runs/${encodeURIComponent(runId)}/session`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function runRuntimeClientEffect<A>(program: Effect.Effect<A, RuntimeClientError>): Promise<A> {
|
|
338
|
+
const exit = await Effect.runPromiseExit(program);
|
|
339
|
+
if (Exit.isSuccess(exit)) return exit.value;
|
|
340
|
+
throw Cause.squash(exit.cause);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function toRuntimeClientError(cause: unknown, context?: { method: string; path: string }): RuntimeClientError {
|
|
344
|
+
if (isRuntimeClientError(cause)) return cause;
|
|
345
|
+
if (context) {
|
|
346
|
+
return new RuntimeConnectionError({
|
|
347
|
+
method: context.method,
|
|
348
|
+
path: context.path,
|
|
349
|
+
cause,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return new RuntimeStartupError({
|
|
353
|
+
reason: "unhealthy-after-start",
|
|
354
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
355
|
+
cause,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function withRuntimeLock(path: string, run: () => Promise<void>): Promise<void> {
|
|
360
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
361
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
362
|
+
try {
|
|
363
|
+
mkdirSync(path);
|
|
364
|
+
try {
|
|
365
|
+
await run();
|
|
366
|
+
} finally {
|
|
367
|
+
rmSync(path, { recursive: true, force: true });
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (!isFileExistsError(error)) throw error;
|
|
372
|
+
if (isStaleLock(path)) rmSync(path, { recursive: true, force: true });
|
|
373
|
+
await sleep(50);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
throw new RuntimeStartupError({
|
|
377
|
+
reason: "lock-timeout",
|
|
378
|
+
path,
|
|
379
|
+
message: `Timed out waiting for Rigkit runtime lock ${path}`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function resolveRuntimeBin(projectDir: string): string {
|
|
384
|
+
const local = join(projectDir, "node_modules", ".bin", process.platform === "win32" ? "rigkit-project-runtime.cmd" : "rigkit-project-runtime");
|
|
385
|
+
if (existsSync(local)) return local;
|
|
386
|
+
throw new RuntimeStartupError({
|
|
387
|
+
reason: "missing-runtime",
|
|
388
|
+
projectDir,
|
|
389
|
+
path: local,
|
|
390
|
+
message: [
|
|
391
|
+
`No project-local Rigkit runtime found at ${local}.`,
|
|
392
|
+
`Install project dependencies so @rigkit/sdk provides the rigkit-project-runtime binary.`,
|
|
393
|
+
].join("\n"),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function readHandle(path: string): RuntimeHandle | undefined {
|
|
398
|
+
if (!existsSync(path)) return undefined;
|
|
399
|
+
try {
|
|
400
|
+
return RuntimeHandleSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
401
|
+
} catch {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function readToken(path: string): string | undefined {
|
|
407
|
+
if (!existsSync(path)) return undefined;
|
|
408
|
+
const token = readFileSync(path, "utf8").trim();
|
|
409
|
+
return token || undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function createToken(path: string): string {
|
|
413
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
414
|
+
const token = `rigkit_${randomUUID().replaceAll("-", "")}`;
|
|
415
|
+
writeFileSync(path, `${token}\n`);
|
|
416
|
+
try {
|
|
417
|
+
chmodSync(path, 0o600);
|
|
418
|
+
} catch {
|
|
419
|
+
// Best effort on platforms without chmod support.
|
|
420
|
+
}
|
|
421
|
+
return token;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function readReadyLine(proc: ChildProcessByStdio<null, Readable, null>, paths: RuntimePaths, projectDir: string): Promise<string> {
|
|
425
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
426
|
+
let buffer = "";
|
|
427
|
+
let settled = false;
|
|
428
|
+
const timer = setTimeout(() => {
|
|
429
|
+
fail(new RuntimeStartupError({
|
|
430
|
+
reason: "startup-timeout",
|
|
431
|
+
projectDir,
|
|
432
|
+
message: `Timed out waiting for Rigkit runtime to start`,
|
|
433
|
+
}), { kill: true });
|
|
434
|
+
}, 15_000);
|
|
435
|
+
|
|
436
|
+
const cleanup = () => {
|
|
437
|
+
clearTimeout(timer);
|
|
438
|
+
proc.stdout.off("data", onData);
|
|
439
|
+
proc.stdout.off("end", onEnd);
|
|
440
|
+
proc.stdout.off("error", fail);
|
|
441
|
+
proc.off("error", fail);
|
|
442
|
+
proc.off("exit", onExit);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const resolveLine = (line: string) => {
|
|
446
|
+
if (settled) return;
|
|
447
|
+
settled = true;
|
|
448
|
+
cleanup();
|
|
449
|
+
resolvePromise(line);
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
function fail(error: unknown, options: { kill?: boolean } = {}) {
|
|
453
|
+
if (settled) return;
|
|
454
|
+
settled = true;
|
|
455
|
+
cleanup();
|
|
456
|
+
if (options.kill) killRuntimeProcess(proc);
|
|
457
|
+
removeStale(paths);
|
|
458
|
+
rejectPromise(error);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function onData(chunk: Buffer | string) {
|
|
462
|
+
buffer += String(chunk);
|
|
463
|
+
const index = buffer.indexOf("\n");
|
|
464
|
+
if (index >= 0) resolveLine(buffer.slice(0, index));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function onEnd() {
|
|
468
|
+
fail(new RuntimeStartupError({
|
|
469
|
+
reason: "exited-before-ready",
|
|
470
|
+
projectDir,
|
|
471
|
+
message: `Rigkit runtime exited before ready`,
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function onExit(code: number | null, signal: NodeJS.Signals | null) {
|
|
476
|
+
if (code === 0) return;
|
|
477
|
+
removeStale(paths);
|
|
478
|
+
fail(new RuntimeStartupError({
|
|
479
|
+
reason: "exited-before-ready",
|
|
480
|
+
projectDir,
|
|
481
|
+
message: `Rigkit runtime exited before ready (${signal ?? `exit ${code}`})`,
|
|
482
|
+
}));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
proc.stdout.setEncoding("utf8");
|
|
486
|
+
proc.stdout.on("data", onData);
|
|
487
|
+
proc.stdout.once("end", onEnd);
|
|
488
|
+
proc.stdout.once("error", fail);
|
|
489
|
+
proc.once("error", fail);
|
|
490
|
+
proc.once("exit", onExit);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function killRuntimeProcess(proc: ChildProcessByStdio<null, Readable, null>): void {
|
|
495
|
+
if (!proc.pid) return;
|
|
496
|
+
try {
|
|
497
|
+
proc.kill("SIGTERM");
|
|
498
|
+
} catch {
|
|
499
|
+
// Best effort. The startup path will discard the stale handle either way.
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function removeStale(paths: RuntimePaths): void {
|
|
504
|
+
rmSync(paths.handlePath, { force: true });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function shutdownRuntime(handle: RuntimeHandle, token: string): Promise<void> {
|
|
508
|
+
try {
|
|
509
|
+
await createRuntimeHttpClient({ baseUrl: handle.url, token }).shutdown();
|
|
510
|
+
} catch {
|
|
511
|
+
if (handle.pid !== process.pid) {
|
|
512
|
+
try {
|
|
513
|
+
process.kill(handle.pid);
|
|
514
|
+
} catch {
|
|
515
|
+
// Best effort. The stale handle is still removed below.
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function updateFileFingerprint(hash: ReturnType<typeof createHash>, label: string, path: string): void {
|
|
522
|
+
hash.update(`\0${label}\0${path}\0`);
|
|
523
|
+
if (!existsSync(path)) {
|
|
524
|
+
hash.update("missing");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const stat = statSync(path);
|
|
529
|
+
if (!stat.isFile()) {
|
|
530
|
+
hash.update(`not-file:${stat.mode}`);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
hash.update(readFileSync(path));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function projectFingerprintFiles(projectDir: string): string[] {
|
|
538
|
+
return [
|
|
539
|
+
"package.json",
|
|
540
|
+
"bun.lock",
|
|
541
|
+
"bun.lockb",
|
|
542
|
+
"pnpm-lock.yaml",
|
|
543
|
+
"package-lock.json",
|
|
544
|
+
"yarn.lock",
|
|
545
|
+
].map((file) => join(projectDir, file));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function updateProjectSurfaceFingerprint(hash: ReturnType<typeof createHash>, projectDir: string): void {
|
|
549
|
+
if (!existsSync(projectDir)) return;
|
|
550
|
+
const ignored = new Set([".git", ".rigkit", "node_modules", "dist", "build", ".next", ".astro"]);
|
|
551
|
+
const entries = readdirSync(projectDir, { withFileTypes: true })
|
|
552
|
+
.filter((entry) => !ignored.has(entry.name))
|
|
553
|
+
.map((entry) => `${entry.name}:${entry.isDirectory() ? "dir" : entry.isFile() ? "file" : "other"}`)
|
|
554
|
+
.sort();
|
|
555
|
+
hash.update("\0project-surface\0");
|
|
556
|
+
hash.update(entries.join("\n"));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function updateRigkitPackageFingerprint(hash: ReturnType<typeof createHash>, scopeDir: string): void {
|
|
560
|
+
// Walk every @rigkit scope reachable from the project's node_modules. We
|
|
561
|
+
// recurse into each package's own `node_modules/@rigkit` so that nested
|
|
562
|
+
// installs (e.g. @rigkit/engine living under @rigkit/sdk/node_modules) are
|
|
563
|
+
// hashed too. Without this, edits to a transitive @rigkit package wouldn't
|
|
564
|
+
// shift the runtime fingerprint and the daemon wouldn't auto-restart.
|
|
565
|
+
const visited = new Set<string>();
|
|
566
|
+
const stack: string[] = [scopeDir];
|
|
567
|
+
|
|
568
|
+
while (stack.length > 0) {
|
|
569
|
+
const dir = stack.pop()!;
|
|
570
|
+
if (!existsSync(dir)) continue;
|
|
571
|
+
let canonical: string;
|
|
572
|
+
try {
|
|
573
|
+
canonical = realpathSync(dir);
|
|
574
|
+
} catch {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (visited.has(canonical)) continue;
|
|
578
|
+
visited.add(canonical);
|
|
579
|
+
|
|
580
|
+
const packageDirs = readdirSync(dir, { withFileTypes: true })
|
|
581
|
+
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
582
|
+
.map((entry) => join(dir, entry.name))
|
|
583
|
+
.sort();
|
|
584
|
+
|
|
585
|
+
for (const packageDir of packageDirs) {
|
|
586
|
+
updateFileFingerprint(hash, "rigkit-package", join(packageDir, "package.json"));
|
|
587
|
+
for (const file of collectFiles(join(packageDir, "src"))) {
|
|
588
|
+
updateFileFingerprint(hash, "rigkit-source", file);
|
|
589
|
+
}
|
|
590
|
+
// Recurse into this package's own @rigkit scope, if it has nested deps.
|
|
591
|
+
stack.push(join(packageDir, "node_modules", "@rigkit"));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function collectFiles(root: string): string[] {
|
|
597
|
+
if (!existsSync(root)) return [];
|
|
598
|
+
const out: string[] = [];
|
|
599
|
+
const visit = (dir: string) => {
|
|
600
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
601
|
+
const path = join(dir, entry.name);
|
|
602
|
+
if (entry.isDirectory()) {
|
|
603
|
+
visit(path);
|
|
604
|
+
} else if (entry.isFile()) {
|
|
605
|
+
out.push(path);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
visit(root);
|
|
610
|
+
return out.sort();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function dotenvFilesFor(projectDir: string): string[] {
|
|
614
|
+
const files: string[] = [];
|
|
615
|
+
let current = projectDir;
|
|
616
|
+
|
|
617
|
+
while (true) {
|
|
618
|
+
const candidate = join(current, ".env");
|
|
619
|
+
if (existsSync(candidate)) files.unshift(candidate);
|
|
620
|
+
|
|
621
|
+
const parent = dirname(current);
|
|
622
|
+
if (parent === current) break;
|
|
623
|
+
current = parent;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return files;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function isFileExistsError(error: unknown): boolean {
|
|
630
|
+
return Boolean(error && typeof error === "object" && (error as { code?: unknown }).code === "EEXIST");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function isStaleLock(path: string): boolean {
|
|
634
|
+
try {
|
|
635
|
+
return Date.now() - statSync(path).mtimeMs > 10_000;
|
|
636
|
+
} catch {
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function sleep(ms: number): Promise<void> {
|
|
642
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
643
|
+
}
|