@rigkit/runtime-client 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigkit/runtime-client",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/api.ts CHANGED
@@ -14,6 +14,7 @@ const OptionalString = Schema.optional(Schema.String);
14
14
  export const RuntimeControlHealthEffectSchema = Schema.Struct({
15
15
  ok: Schema.Boolean,
16
16
  projectId: Schema.String,
17
+ runtimeFingerprint: OptionalString,
17
18
  projectDir: Schema.String,
18
19
  configPath: Schema.String,
19
20
  statePath: OptionalString,
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  getOrStartRuntime,
4
4
  connectRemoteRuntime,
5
5
  projectIdFor,
6
+ runtimeFingerprintFor,
6
7
  runtimePaths,
7
8
  getOrStartRuntimeEffect,
8
9
  type GetOrStartRuntimeOptions,
@@ -1,11 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  connectRemoteRuntime,
7
7
  getOrStartRuntime,
8
8
  projectIdFor,
9
+ runtimeFingerprintFor,
9
10
  runtimePaths,
10
11
  SUPPORTED_RUNTIME_API_VERSION,
11
12
  } from "./manager.ts";
@@ -42,7 +43,7 @@ describe("runtime manager", () => {
42
43
  expect(differentSource).not.toBe(first);
43
44
  });
44
45
 
45
- test("includes config contents in local runtime ids", () => {
46
+ test("keeps project ids stable while config fingerprints change", () => {
46
47
  const root = mkdtempSync(join(tmpdir(), "rigkit-runtime-client-id-"));
47
48
  try {
48
49
  const projectDir = join(root, "project");
@@ -52,16 +53,62 @@ describe("runtime manager", () => {
52
53
 
53
54
  const first = projectIdFor({ projectDir, configPath });
54
55
  const second = projectIdFor({ projectDir, configPath });
56
+ const firstFingerprint = runtimeFingerprintFor({ projectDir, configPath });
55
57
  writeFileSync(configPath, "export default { name: 'two' }\n");
56
58
  const changed = projectIdFor({ projectDir, configPath });
59
+ const changedFingerprint = runtimeFingerprintFor({ projectDir, configPath });
57
60
 
58
61
  expect(second).toBe(first);
59
- expect(changed).not.toBe(first);
62
+ expect(changed).toBe(first);
63
+ expect(changedFingerprint).not.toBe(firstFingerprint);
60
64
  } finally {
61
65
  rmSync(root, { recursive: true, force: true });
62
66
  }
63
67
  });
64
68
 
69
+ test("restarts local runtimes when the runtime fingerprint changes", async () => {
70
+ const root = mkdtempSync(join(tmpdir(), "rigkit-runtime-client-restart-"));
71
+ let first: Awaited<ReturnType<typeof getOrStartRuntime>> | undefined;
72
+ let second: Awaited<ReturnType<typeof getOrStartRuntime>> | undefined;
73
+
74
+ try {
75
+ const projectDir = join(root, "project");
76
+ const rigkitHome = join(root, "home");
77
+ const configPath = join(projectDir, "rig.config.ts");
78
+ mkdirSync(projectDir, { recursive: true });
79
+ writeFileSync(configPath, "export default { name: 'one' }\n");
80
+ writeFakeRuntimeBin(projectDir);
81
+
82
+ first = await getOrStartRuntime({
83
+ projectDir,
84
+ configPath,
85
+ rigkitHome,
86
+ idleMs: 60_000,
87
+ });
88
+ const firstHealth = await first.control.health();
89
+
90
+ writeFileSync(configPath, "export default { name: 'two' }\n");
91
+ second = await getOrStartRuntime({
92
+ projectDir,
93
+ configPath,
94
+ rigkitHome,
95
+ idleMs: 60_000,
96
+ });
97
+ const secondHealth = await second.control.health();
98
+
99
+ expect(second.handle.projectId).toBe(first.handle.projectId);
100
+ expect(second.paths.handlePath).toBe(first.paths.handlePath);
101
+ expect(second.handle.runtimeFingerprint).not.toBe(first.handle.runtimeFingerprint);
102
+ expect(second.handle.pid).not.toBe(first.handle.pid);
103
+ expect(secondHealth.runtimeFingerprint).toBe(second.handle.runtimeFingerprint);
104
+ expect(firstHealth.projectId).toBe(secondHealth.projectId);
105
+ } finally {
106
+ await second?.control.shutdown().catch(() => {});
107
+ await first?.control.shutdown().catch(() => {});
108
+ rmSync(root, { recursive: true, force: true });
109
+ }
110
+ });
111
+
65
112
  test("derives handle, token, and lock paths from rigkit home", () => {
66
113
  const paths = runtimePaths("sha256-test", "/tmp/rigkit-home");
67
114
 
@@ -229,3 +276,72 @@ describe("runtime manager", () => {
229
276
  }
230
277
  });
231
278
  });
279
+
280
+ function writeFakeRuntimeBin(projectDir: string): void {
281
+ const binDir = join(projectDir, "node_modules", ".bin");
282
+ mkdirSync(binDir, { recursive: true });
283
+ const binPath = join(binDir, process.platform === "win32" ? "rigkit-project-runtime.cmd" : "rigkit-project-runtime");
284
+ writeFileSync(
285
+ binPath,
286
+ `#!/usr/bin/env bun
287
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
288
+ import { dirname, resolve } from "node:path";
289
+
290
+ const args = process.argv.slice(2);
291
+ const options = {};
292
+ for (let i = 1; i < args.length; i += 2) {
293
+ options[args[i].replace(/^--/, "")] = args[i + 1];
294
+ }
295
+
296
+ const token = readFileSync(options.token, "utf8").trim();
297
+ let server;
298
+ server = Bun.serve({
299
+ hostname: "127.0.0.1",
300
+ port: 0,
301
+ fetch(request) {
302
+ const url = new URL(request.url);
303
+ if (request.headers.get("authorization") !== \`Bearer \${token}\`) {
304
+ return Response.json({ error: { message: "Unauthorized" } }, { status: 401 });
305
+ }
306
+ if (url.pathname === "/health") {
307
+ return Response.json({
308
+ ok: true,
309
+ projectId: options["project-id"],
310
+ runtimeFingerprint: options["runtime-fingerprint"],
311
+ projectDir: resolve(options["project-dir"]),
312
+ configPath: resolve(options.config),
313
+ statePath: options.state ? resolve(options.state) : undefined,
314
+ engineVersion: "engine-test",
315
+ runtimeVersion: "runtime-test",
316
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
317
+ }, { headers: { "x-rigkit-api-version": "${SUPPORTED_RUNTIME_API_VERSION}" } });
318
+ }
319
+ if (url.pathname === "/shutdown") {
320
+ setTimeout(() => {
321
+ server.stop(true);
322
+ process.exit(0);
323
+ }, 0);
324
+ return Response.json({ ok: true }, { headers: { "x-rigkit-api-version": "${SUPPORTED_RUNTIME_API_VERSION}" } });
325
+ }
326
+ return Response.json({ error: { message: "Not found" } }, { status: 404 });
327
+ },
328
+ });
329
+
330
+ const handle = {
331
+ projectId: options["project-id"],
332
+ runtimeFingerprint: options["runtime-fingerprint"],
333
+ projectDir: resolve(options["project-dir"]),
334
+ configPath: resolve(options.config),
335
+ statePath: options.state ? resolve(options.state) : undefined,
336
+ pid: process.pid,
337
+ url: \`http://127.0.0.1:\${server.port}\`,
338
+ tokenPath: resolve(options.token),
339
+ };
340
+ mkdirSync(dirname(options.handle), { recursive: true });
341
+ writeFileSync(options.handle, JSON.stringify(handle));
342
+ console.log(JSON.stringify({ type: "ready", url: handle.url, token }));
343
+ await new Promise(() => {});
344
+ `,
345
+ );
346
+ chmodSync(binPath, 0o755);
347
+ }
package/src/manager.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { spawn, type ChildProcessByStdio } from "node:child_process";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { dirname, join, resolve } from "node:path";
@@ -93,13 +93,19 @@ async function getOrStartRuntimeUnsafe(options: GetOrStartRuntimeOptions): Promi
93
93
  statePath,
94
94
  source: options.source,
95
95
  });
96
+ const runtimeFingerprint = runtimeFingerprintFor({
97
+ projectDir,
98
+ configPath,
99
+ statePath,
100
+ source: options.source,
101
+ });
96
102
  const paths = runtimePaths(projectId, options.rigkitHome);
97
103
 
98
- const existing = await tryExistingRuntime(paths, projectId);
104
+ const existing = await tryExistingRuntime(paths, projectId, runtimeFingerprint);
99
105
  if (existing) return existing;
100
106
 
101
107
  await withRuntimeLock(paths.lockPath, async () => {
102
- const secondCheck = await tryExistingRuntime(paths, projectId);
108
+ const secondCheck = await tryExistingRuntime(paths, projectId, runtimeFingerprint);
103
109
  if (secondCheck) return;
104
110
  await startRuntime({
105
111
  ...options,
@@ -107,11 +113,12 @@ async function getOrStartRuntimeUnsafe(options: GetOrStartRuntimeOptions): Promi
107
113
  configPath,
108
114
  statePath,
109
115
  projectId,
116
+ runtimeFingerprint,
110
117
  paths,
111
118
  });
112
119
  });
113
120
 
114
- const started = await tryExistingRuntime(paths, projectId);
121
+ const started = await tryExistingRuntime(paths, projectId, runtimeFingerprint);
115
122
  if (!started) {
116
123
  throw new RuntimeStartupError({
117
124
  reason: "unhealthy-after-start",
@@ -128,13 +135,36 @@ export function projectIdFor(options: RuntimeProjectOptions): string {
128
135
  hash.update(JSON.stringify({
129
136
  projectDir: resolve(options.projectDir),
130
137
  configPath,
131
- configHash: configHashFor(configPath),
132
138
  statePath: options.statePath ? resolve(options.statePath) : null,
133
139
  source: options.source ?? null,
134
140
  }));
135
141
  return `sha256-${hash.digest("hex").slice(0, 32)}`;
136
142
  }
137
143
 
144
+ export function runtimeFingerprintFor(options: RuntimeProjectOptions): string {
145
+ const projectDir = resolve(options.projectDir);
146
+ const configPath = resolve(options.configPath);
147
+ const statePath = options.statePath ? resolve(options.statePath) : null;
148
+ const hash = createHash("sha256");
149
+
150
+ hash.update("project\0");
151
+ hash.update(projectDir);
152
+ hash.update("\0config\0");
153
+ hash.update(configPath);
154
+ hash.update("\0state\0");
155
+ hash.update(statePath ?? "");
156
+ hash.update("\0source\0");
157
+ hash.update(JSON.stringify(options.source ?? null));
158
+
159
+ updateFileFingerprint(hash, "config", configPath);
160
+ for (const file of dotenvFilesFor(projectDir)) updateFileFingerprint(hash, "dotenv", file);
161
+ for (const file of projectFingerprintFiles(projectDir)) updateFileFingerprint(hash, "project-file", file);
162
+ updateProjectSurfaceFingerprint(hash, projectDir);
163
+ updateRigkitPackageFingerprint(hash, join(projectDir, "node_modules", "@rigkit"));
164
+
165
+ return `sha256-${hash.digest("hex")}`;
166
+ }
167
+
138
168
  export function runtimePaths(projectId: string, rigkitHome = defaultRigkitHome()): RuntimePaths {
139
169
  const root = join(rigkitHome, "runtimes");
140
170
  return {
@@ -149,11 +179,24 @@ export function defaultRigkitHome(): string {
149
179
  return process.env.RIGKIT_HOME ? resolve(process.env.RIGKIT_HOME) : join(homedir(), ".rigkit");
150
180
  }
151
181
 
152
- async function tryExistingRuntime(paths: RuntimePaths, projectId: string): Promise<RuntimeClient | undefined> {
182
+ async function tryExistingRuntime(
183
+ paths: RuntimePaths,
184
+ projectId: string,
185
+ runtimeFingerprint: string,
186
+ ): Promise<RuntimeClient | undefined> {
153
187
  const handle = readHandle(paths.handlePath);
154
188
  if (!handle || handle.projectId !== projectId) return undefined;
155
189
  const token = readToken(handle.tokenPath);
156
- if (!token) return undefined;
190
+ if (!token) {
191
+ removeStale(paths);
192
+ return undefined;
193
+ }
194
+
195
+ if (handle.runtimeFingerprint !== runtimeFingerprint) {
196
+ await shutdownRuntime(handle, token);
197
+ removeStale(paths);
198
+ return undefined;
199
+ }
157
200
 
158
201
  try {
159
202
  const body = await createRuntimeHttpClient({ baseUrl: handle.url, token }).health();
@@ -164,6 +207,13 @@ async function tryExistingRuntime(paths: RuntimePaths, projectId: string): Promi
164
207
  message: `runtime project mismatch`,
165
208
  });
166
209
  }
210
+ if (body.runtimeFingerprint !== runtimeFingerprint) {
211
+ throw new RuntimeConnectionError({
212
+ method: "GET",
213
+ path: "/health",
214
+ message: `runtime fingerprint mismatch`,
215
+ });
216
+ }
167
217
  return createClient(handle, paths, token);
168
218
  } catch (error) {
169
219
  if (error instanceof RuntimeApiVersionError) throw error;
@@ -174,6 +224,7 @@ async function tryExistingRuntime(paths: RuntimePaths, projectId: string): Promi
174
224
 
175
225
  async function startRuntime(input: GetOrStartRuntimeOptions & {
176
226
  projectId: string;
227
+ runtimeFingerprint: string;
177
228
  paths: RuntimePaths;
178
229
  }): Promise<void> {
179
230
  mkdirSync(input.paths.root, { recursive: true });
@@ -183,6 +234,8 @@ async function startRuntime(input: GetOrStartRuntimeOptions & {
183
234
  "serve",
184
235
  "--project-id",
185
236
  input.projectId,
237
+ "--runtime-fingerprint",
238
+ input.runtimeFingerprint,
186
239
  "--project-dir",
187
240
  input.projectDir,
188
241
  "--config",
@@ -355,7 +408,7 @@ function readReadyLine(proc: ChildProcessByStdio<null, Readable, null>, paths: R
355
408
  reason: "startup-timeout",
356
409
  projectDir,
357
410
  message: `Timed out waiting for Rigkit runtime to start`,
358
- }));
411
+ }), { kill: true });
359
412
  }, 15_000);
360
413
 
361
414
  const cleanup = () => {
@@ -374,10 +427,12 @@ function readReadyLine(proc: ChildProcessByStdio<null, Readable, null>, paths: R
374
427
  resolvePromise(line);
375
428
  };
376
429
 
377
- function fail(error: unknown) {
430
+ function fail(error: unknown, options: { kill?: boolean } = {}) {
378
431
  if (settled) return;
379
432
  settled = true;
380
433
  cleanup();
434
+ if (options.kill) killRuntimeProcess(proc);
435
+ removeStale(paths);
381
436
  rejectPromise(error);
382
437
  }
383
438
 
@@ -414,15 +469,117 @@ function readReadyLine(proc: ChildProcessByStdio<null, Readable, null>, paths: R
414
469
  });
415
470
  }
416
471
 
472
+ function killRuntimeProcess(proc: ChildProcessByStdio<null, Readable, null>): void {
473
+ if (!proc.pid) return;
474
+ try {
475
+ proc.kill("SIGTERM");
476
+ } catch {
477
+ // Best effort. The startup path will discard the stale handle either way.
478
+ }
479
+ }
480
+
417
481
  function removeStale(paths: RuntimePaths): void {
418
482
  rmSync(paths.handlePath, { force: true });
419
483
  }
420
484
 
421
- function configHashFor(configPath: string): string | null {
422
- if (!existsSync(configPath)) return null;
423
- const hash = createHash("sha256");
424
- hash.update(readFileSync(configPath));
425
- return hash.digest("hex");
485
+ async function shutdownRuntime(handle: RuntimeHandle, token: string): Promise<void> {
486
+ try {
487
+ await createRuntimeHttpClient({ baseUrl: handle.url, token }).shutdown();
488
+ } catch {
489
+ if (handle.pid !== process.pid) {
490
+ try {
491
+ process.kill(handle.pid);
492
+ } catch {
493
+ // Best effort. The stale handle is still removed below.
494
+ }
495
+ }
496
+ }
497
+ }
498
+
499
+ function updateFileFingerprint(hash: ReturnType<typeof createHash>, label: string, path: string): void {
500
+ hash.update(`\0${label}\0${path}\0`);
501
+ if (!existsSync(path)) {
502
+ hash.update("missing");
503
+ return;
504
+ }
505
+
506
+ const stat = statSync(path);
507
+ if (!stat.isFile()) {
508
+ hash.update(`not-file:${stat.mode}`);
509
+ return;
510
+ }
511
+
512
+ hash.update(readFileSync(path));
513
+ }
514
+
515
+ function projectFingerprintFiles(projectDir: string): string[] {
516
+ return [
517
+ "package.json",
518
+ "bun.lock",
519
+ "bun.lockb",
520
+ "pnpm-lock.yaml",
521
+ "package-lock.json",
522
+ "yarn.lock",
523
+ ].map((file) => join(projectDir, file));
524
+ }
525
+
526
+ function updateProjectSurfaceFingerprint(hash: ReturnType<typeof createHash>, projectDir: string): void {
527
+ if (!existsSync(projectDir)) return;
528
+ const ignored = new Set([".git", ".rigkit", "node_modules", "dist", "build", ".next", ".astro"]);
529
+ const entries = readdirSync(projectDir, { withFileTypes: true })
530
+ .filter((entry) => !ignored.has(entry.name))
531
+ .map((entry) => `${entry.name}:${entry.isDirectory() ? "dir" : entry.isFile() ? "file" : "other"}`)
532
+ .sort();
533
+ hash.update("\0project-surface\0");
534
+ hash.update(entries.join("\n"));
535
+ }
536
+
537
+ function updateRigkitPackageFingerprint(hash: ReturnType<typeof createHash>, scopeDir: string): void {
538
+ if (!existsSync(scopeDir)) return;
539
+ const packageDirs = readdirSync(scopeDir, { withFileTypes: true })
540
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
541
+ .map((entry) => join(scopeDir, entry.name))
542
+ .sort();
543
+
544
+ for (const packageDir of packageDirs) {
545
+ updateFileFingerprint(hash, "rigkit-package", join(packageDir, "package.json"));
546
+ for (const file of collectFiles(join(packageDir, "src"))) {
547
+ updateFileFingerprint(hash, "rigkit-source", file);
548
+ }
549
+ }
550
+ }
551
+
552
+ function collectFiles(root: string): string[] {
553
+ if (!existsSync(root)) return [];
554
+ const out: string[] = [];
555
+ const visit = (dir: string) => {
556
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
557
+ const path = join(dir, entry.name);
558
+ if (entry.isDirectory()) {
559
+ visit(path);
560
+ } else if (entry.isFile()) {
561
+ out.push(path);
562
+ }
563
+ }
564
+ };
565
+ visit(root);
566
+ return out.sort();
567
+ }
568
+
569
+ function dotenvFilesFor(projectDir: string): string[] {
570
+ const files: string[] = [];
571
+ let current = projectDir;
572
+
573
+ while (true) {
574
+ const candidate = join(current, ".env");
575
+ if (existsSync(candidate)) files.unshift(candidate);
576
+
577
+ const parent = dirname(current);
578
+ if (parent === current) break;
579
+ current = parent;
580
+ }
581
+
582
+ return files;
426
583
  }
427
584
 
428
585
  function isFileExistsError(error: unknown): boolean {
package/src/schemas.ts CHANGED
@@ -38,6 +38,7 @@ function runtimeClientSchema<T, I>(schema: Schema.Schema<T, I, never>): RuntimeC
38
38
 
39
39
  export const RuntimeHandleEffectSchema = Schema.Struct({
40
40
  projectId: Schema.String,
41
+ runtimeFingerprint: Schema.optional(Schema.String),
41
42
  projectDir: Schema.String,
42
43
  configPath: Schema.String,
43
44
  statePath: Schema.optional(Schema.String),
@@ -59,6 +60,7 @@ export const RuntimeReadyEffectSchema = Schema.Struct({
59
60
  export const RuntimeHealthEffectSchema = Schema.Struct({
60
61
  ok: Schema.Boolean,
61
62
  projectId: Schema.String,
63
+ runtimeFingerprint: Schema.optional(Schema.String),
62
64
  projectDir: Schema.optional(Schema.String),
63
65
  configPath: Schema.optional(Schema.String),
64
66
  statePath: Schema.optional(Schema.String),
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const RIGKIT_RUNTIME_CLIENT_VERSION = "0.2.0";
1
+ export const RIGKIT_RUNTIME_CLIENT_VERSION = "0.2.2";