@rigkit/runtime-client 0.1.8
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 +219 -0
- package/src/client.ts +238 -0
- package/src/errors.ts +124 -0
- package/src/http.ts +96 -0
- package/src/index.ts +100 -0
- package/src/manager.test.ts +231 -0
- package/src/manager.ts +442 -0
- package/src/schemas.ts +93 -0
- package/src/session.ts +140 -0
- package/src/version.ts +1 -0
package/src/manager.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, 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
|
+
source?: unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type RuntimeClient = {
|
|
41
|
+
handle: RuntimeHandle;
|
|
42
|
+
paths: RuntimePaths;
|
|
43
|
+
token: string;
|
|
44
|
+
control: RuntimeHttpClient;
|
|
45
|
+
runEvents(
|
|
46
|
+
runId: string,
|
|
47
|
+
onEvent: (event: unknown) => Promise<void> | void,
|
|
48
|
+
): Promise<void>;
|
|
49
|
+
runSession(
|
|
50
|
+
runId: string,
|
|
51
|
+
handlers: RuntimeSessionHandlers,
|
|
52
|
+
): Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type RemoteRuntimeOptions = {
|
|
56
|
+
url: string;
|
|
57
|
+
token: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type GetOrStartRuntimeOptions = RuntimeProjectOptions & {
|
|
61
|
+
rigkitHome?: string;
|
|
62
|
+
idleMs?: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type RuntimePaths = {
|
|
66
|
+
root: string;
|
|
67
|
+
handlePath: string;
|
|
68
|
+
tokenPath: string;
|
|
69
|
+
lockPath: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const DEFAULT_IDLE_MS = 30 * 60 * 1000;
|
|
73
|
+
export { SUPPORTED_RUNTIME_API_VERSION };
|
|
74
|
+
|
|
75
|
+
export async function getOrStartRuntime(options: GetOrStartRuntimeOptions): Promise<RuntimeClient> {
|
|
76
|
+
return runRuntimeClientEffect(getOrStartRuntimeEffect(options));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getOrStartRuntimeEffect(options: GetOrStartRuntimeOptions): Effect.Effect<RuntimeClient, RuntimeClientError> {
|
|
80
|
+
return Effect.tryPromise({
|
|
81
|
+
try: () => getOrStartRuntimeUnsafe(options),
|
|
82
|
+
catch: toRuntimeClientError,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function getOrStartRuntimeUnsafe(options: GetOrStartRuntimeOptions): Promise<RuntimeClient> {
|
|
87
|
+
const projectDir = resolve(options.projectDir);
|
|
88
|
+
const configPath = resolve(options.configPath);
|
|
89
|
+
const statePath = options.statePath ? resolve(options.statePath) : undefined;
|
|
90
|
+
const projectId = projectIdFor({
|
|
91
|
+
projectDir,
|
|
92
|
+
configPath,
|
|
93
|
+
statePath,
|
|
94
|
+
source: options.source,
|
|
95
|
+
});
|
|
96
|
+
const paths = runtimePaths(projectId, options.rigkitHome);
|
|
97
|
+
|
|
98
|
+
const existing = await tryExistingRuntime(paths, projectId);
|
|
99
|
+
if (existing) return existing;
|
|
100
|
+
|
|
101
|
+
await withRuntimeLock(paths.lockPath, async () => {
|
|
102
|
+
const secondCheck = await tryExistingRuntime(paths, projectId);
|
|
103
|
+
if (secondCheck) return;
|
|
104
|
+
await startRuntime({
|
|
105
|
+
...options,
|
|
106
|
+
projectDir,
|
|
107
|
+
configPath,
|
|
108
|
+
statePath,
|
|
109
|
+
projectId,
|
|
110
|
+
paths,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const started = await tryExistingRuntime(paths, projectId);
|
|
115
|
+
if (!started) {
|
|
116
|
+
throw new RuntimeStartupError({
|
|
117
|
+
reason: "unhealthy-after-start",
|
|
118
|
+
projectDir,
|
|
119
|
+
message: `Rigkit runtime did not become healthy for ${projectDir}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return started;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function projectIdFor(options: RuntimeProjectOptions): string {
|
|
126
|
+
const configPath = resolve(options.configPath);
|
|
127
|
+
const hash = createHash("sha256");
|
|
128
|
+
hash.update(JSON.stringify({
|
|
129
|
+
projectDir: resolve(options.projectDir),
|
|
130
|
+
configPath,
|
|
131
|
+
configHash: configHashFor(configPath),
|
|
132
|
+
statePath: options.statePath ? resolve(options.statePath) : null,
|
|
133
|
+
source: options.source ?? null,
|
|
134
|
+
}));
|
|
135
|
+
return `sha256-${hash.digest("hex").slice(0, 32)}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function runtimePaths(projectId: string, rigkitHome = defaultRigkitHome()): RuntimePaths {
|
|
139
|
+
const root = join(rigkitHome, "runtimes");
|
|
140
|
+
return {
|
|
141
|
+
root,
|
|
142
|
+
handlePath: join(root, `${projectId}.json`),
|
|
143
|
+
tokenPath: join(root, `${projectId}.token`),
|
|
144
|
+
lockPath: join(root, `${projectId}.lock`),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function defaultRigkitHome(): string {
|
|
149
|
+
return process.env.RIGKIT_HOME ? resolve(process.env.RIGKIT_HOME) : join(homedir(), ".rigkit");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function tryExistingRuntime(paths: RuntimePaths, projectId: string): Promise<RuntimeClient | undefined> {
|
|
153
|
+
const handle = readHandle(paths.handlePath);
|
|
154
|
+
if (!handle || handle.projectId !== projectId) return undefined;
|
|
155
|
+
const token = readToken(handle.tokenPath);
|
|
156
|
+
if (!token) return undefined;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const body = await createRuntimeHttpClient({ baseUrl: handle.url, token }).health();
|
|
160
|
+
if (body.projectId !== projectId) {
|
|
161
|
+
throw new RuntimeConnectionError({
|
|
162
|
+
method: "GET",
|
|
163
|
+
path: "/health",
|
|
164
|
+
message: `runtime project mismatch`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return createClient(handle, paths, token);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof RuntimeApiVersionError) throw error;
|
|
170
|
+
removeStale(paths);
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function startRuntime(input: GetOrStartRuntimeOptions & {
|
|
176
|
+
projectId: string;
|
|
177
|
+
paths: RuntimePaths;
|
|
178
|
+
}): Promise<void> {
|
|
179
|
+
mkdirSync(input.paths.root, { recursive: true });
|
|
180
|
+
const token = readToken(input.paths.tokenPath) ?? createToken(input.paths.tokenPath);
|
|
181
|
+
const runtimeBin = resolveRuntimeBin(input.projectDir);
|
|
182
|
+
const args = [
|
|
183
|
+
"serve",
|
|
184
|
+
"--project-id",
|
|
185
|
+
input.projectId,
|
|
186
|
+
"--project-dir",
|
|
187
|
+
input.projectDir,
|
|
188
|
+
"--config",
|
|
189
|
+
input.configPath,
|
|
190
|
+
"--handle",
|
|
191
|
+
input.paths.handlePath,
|
|
192
|
+
"--token",
|
|
193
|
+
input.paths.tokenPath,
|
|
194
|
+
"--idle-ms",
|
|
195
|
+
String(input.idleMs ?? DEFAULT_IDLE_MS),
|
|
196
|
+
];
|
|
197
|
+
if (input.statePath) args.push("--state", input.statePath);
|
|
198
|
+
if (input.source !== undefined) args.push("--source-json", JSON.stringify(input.source));
|
|
199
|
+
|
|
200
|
+
const proc = spawn(runtimeBin, args, {
|
|
201
|
+
detached: true,
|
|
202
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
203
|
+
env: process.env,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const line = await readReadyLine(proc, input.paths, input.projectDir);
|
|
207
|
+
let ready: ReturnType<typeof RuntimeReadySchema.parse>;
|
|
208
|
+
try {
|
|
209
|
+
ready = RuntimeReadySchema.parse(JSON.parse(line));
|
|
210
|
+
} catch (cause) {
|
|
211
|
+
throw new RuntimeStartupError({
|
|
212
|
+
reason: "invalid-ready-output",
|
|
213
|
+
projectDir: input.projectDir,
|
|
214
|
+
message: `Rigkit runtime printed invalid ready output`,
|
|
215
|
+
cause,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (ready.token && ready.token !== token) {
|
|
219
|
+
writeFileSync(input.paths.tokenPath, `${ready.token}\n`);
|
|
220
|
+
}
|
|
221
|
+
proc.stdout.destroy();
|
|
222
|
+
proc.unref();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function createClient(handle: RuntimeHandle, paths: RuntimePaths, token: string): RuntimeClient {
|
|
226
|
+
return {
|
|
227
|
+
handle,
|
|
228
|
+
paths,
|
|
229
|
+
token,
|
|
230
|
+
control: createRuntimeHttpClient({ baseUrl: handle.url, token }),
|
|
231
|
+
runEvents: (runId, onEvent) =>
|
|
232
|
+
runRuntimeClientEffect(runtimeStreamEffect(handle.url, token, runEventsPath(runId), onEvent)),
|
|
233
|
+
runSession: (runId, handlers) =>
|
|
234
|
+
runRuntimeClientEffect(runtimeSessionEffect(handle.url, token, runSessionPath(runId), handlers)),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function connectRemoteRuntime(
|
|
239
|
+
options: RemoteRuntimeOptions,
|
|
240
|
+
): Pick<RuntimeClient, "token" | "control" | "runEvents" | "runSession"> {
|
|
241
|
+
const url = options.url.replace(/\/+$/, "");
|
|
242
|
+
const token = options.token;
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
token,
|
|
246
|
+
control: createRuntimeHttpClient({ baseUrl: url, token }),
|
|
247
|
+
runEvents: (runId, onEvent) =>
|
|
248
|
+
runRuntimeClientEffect(runtimeStreamEffect(url, token, runEventsPath(runId), onEvent)),
|
|
249
|
+
runSession: (runId, handlers) =>
|
|
250
|
+
runRuntimeClientEffect(runtimeSessionEffect(url, token, runSessionPath(runId), handlers)),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function runEventsPath(runId: string): string {
|
|
255
|
+
return `/runs/${encodeURIComponent(runId)}/events`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function runSessionPath(runId: string): string {
|
|
259
|
+
return `/runs/${encodeURIComponent(runId)}/session`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function runRuntimeClientEffect<A>(program: Effect.Effect<A, RuntimeClientError>): Promise<A> {
|
|
263
|
+
const exit = await Effect.runPromiseExit(program);
|
|
264
|
+
if (Exit.isSuccess(exit)) return exit.value;
|
|
265
|
+
throw Cause.squash(exit.cause);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function toRuntimeClientError(cause: unknown, context?: { method: string; path: string }): RuntimeClientError {
|
|
269
|
+
if (isRuntimeClientError(cause)) return cause;
|
|
270
|
+
if (context) {
|
|
271
|
+
return new RuntimeConnectionError({
|
|
272
|
+
method: context.method,
|
|
273
|
+
path: context.path,
|
|
274
|
+
cause,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return new RuntimeStartupError({
|
|
278
|
+
reason: "unhealthy-after-start",
|
|
279
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
280
|
+
cause,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function withRuntimeLock(path: string, run: () => Promise<void>): Promise<void> {
|
|
285
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
286
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
287
|
+
try {
|
|
288
|
+
mkdirSync(path);
|
|
289
|
+
try {
|
|
290
|
+
await run();
|
|
291
|
+
} finally {
|
|
292
|
+
rmSync(path, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (!isFileExistsError(error)) throw error;
|
|
297
|
+
if (isStaleLock(path)) rmSync(path, { recursive: true, force: true });
|
|
298
|
+
await sleep(50);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
throw new RuntimeStartupError({
|
|
302
|
+
reason: "lock-timeout",
|
|
303
|
+
path,
|
|
304
|
+
message: `Timed out waiting for Rigkit runtime lock ${path}`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function resolveRuntimeBin(projectDir: string): string {
|
|
309
|
+
const local = join(projectDir, "node_modules", ".bin", process.platform === "win32" ? "rigkit-project-runtime.cmd" : "rigkit-project-runtime");
|
|
310
|
+
if (existsSync(local)) return local;
|
|
311
|
+
throw new RuntimeStartupError({
|
|
312
|
+
reason: "missing-runtime",
|
|
313
|
+
projectDir,
|
|
314
|
+
path: local,
|
|
315
|
+
message: [
|
|
316
|
+
`No project-local Rigkit runtime found at ${local}.`,
|
|
317
|
+
`Install project dependencies so @rigkit/sdk provides the rigkit-project-runtime binary.`,
|
|
318
|
+
].join("\n"),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function readHandle(path: string): RuntimeHandle | undefined {
|
|
323
|
+
if (!existsSync(path)) return undefined;
|
|
324
|
+
try {
|
|
325
|
+
return RuntimeHandleSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
326
|
+
} catch {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function readToken(path: string): string | undefined {
|
|
332
|
+
if (!existsSync(path)) return undefined;
|
|
333
|
+
const token = readFileSync(path, "utf8").trim();
|
|
334
|
+
return token || undefined;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function createToken(path: string): string {
|
|
338
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
339
|
+
const token = `rigkit_${randomUUID().replaceAll("-", "")}`;
|
|
340
|
+
writeFileSync(path, `${token}\n`);
|
|
341
|
+
try {
|
|
342
|
+
chmodSync(path, 0o600);
|
|
343
|
+
} catch {
|
|
344
|
+
// Best effort on platforms without chmod support.
|
|
345
|
+
}
|
|
346
|
+
return token;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function readReadyLine(proc: ChildProcessByStdio<null, Readable, null>, paths: RuntimePaths, projectDir: string): Promise<string> {
|
|
350
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
351
|
+
let buffer = "";
|
|
352
|
+
let settled = false;
|
|
353
|
+
const timer = setTimeout(() => {
|
|
354
|
+
fail(new RuntimeStartupError({
|
|
355
|
+
reason: "startup-timeout",
|
|
356
|
+
projectDir,
|
|
357
|
+
message: `Timed out waiting for Rigkit runtime to start`,
|
|
358
|
+
}));
|
|
359
|
+
}, 15_000);
|
|
360
|
+
|
|
361
|
+
const cleanup = () => {
|
|
362
|
+
clearTimeout(timer);
|
|
363
|
+
proc.stdout.off("data", onData);
|
|
364
|
+
proc.stdout.off("end", onEnd);
|
|
365
|
+
proc.stdout.off("error", fail);
|
|
366
|
+
proc.off("error", fail);
|
|
367
|
+
proc.off("exit", onExit);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const resolveLine = (line: string) => {
|
|
371
|
+
if (settled) return;
|
|
372
|
+
settled = true;
|
|
373
|
+
cleanup();
|
|
374
|
+
resolvePromise(line);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
function fail(error: unknown) {
|
|
378
|
+
if (settled) return;
|
|
379
|
+
settled = true;
|
|
380
|
+
cleanup();
|
|
381
|
+
rejectPromise(error);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function onData(chunk: Buffer | string) {
|
|
385
|
+
buffer += String(chunk);
|
|
386
|
+
const index = buffer.indexOf("\n");
|
|
387
|
+
if (index >= 0) resolveLine(buffer.slice(0, index));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function onEnd() {
|
|
391
|
+
fail(new RuntimeStartupError({
|
|
392
|
+
reason: "exited-before-ready",
|
|
393
|
+
projectDir,
|
|
394
|
+
message: `Rigkit runtime exited before ready`,
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function onExit(code: number | null, signal: NodeJS.Signals | null) {
|
|
399
|
+
if (code === 0) return;
|
|
400
|
+
removeStale(paths);
|
|
401
|
+
fail(new RuntimeStartupError({
|
|
402
|
+
reason: "exited-before-ready",
|
|
403
|
+
projectDir,
|
|
404
|
+
message: `Rigkit runtime exited before ready (${signal ?? `exit ${code}`})`,
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
proc.stdout.setEncoding("utf8");
|
|
409
|
+
proc.stdout.on("data", onData);
|
|
410
|
+
proc.stdout.once("end", onEnd);
|
|
411
|
+
proc.stdout.once("error", fail);
|
|
412
|
+
proc.once("error", fail);
|
|
413
|
+
proc.once("exit", onExit);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function removeStale(paths: RuntimePaths): void {
|
|
418
|
+
rmSync(paths.handlePath, { force: true });
|
|
419
|
+
}
|
|
420
|
+
|
|
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");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function isFileExistsError(error: unknown): boolean {
|
|
429
|
+
return Boolean(error && typeof error === "object" && (error as { code?: unknown }).code === "EEXIST");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isStaleLock(path: string): boolean {
|
|
433
|
+
try {
|
|
434
|
+
return Date.now() - statSync(path).mtimeMs > 10_000;
|
|
435
|
+
} catch {
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function sleep(ms: number): Promise<void> {
|
|
441
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
442
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export class RuntimeClientSchemaError extends Error {
|
|
4
|
+
constructor(readonly cause: unknown) {
|
|
5
|
+
super(String(cause));
|
|
6
|
+
this.name = "RuntimeClientSchemaError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type ParseResult<T> =
|
|
11
|
+
| { success: true; data: T }
|
|
12
|
+
| { success: false; error: RuntimeClientSchemaError };
|
|
13
|
+
|
|
14
|
+
type RuntimeClientSchema<T> = {
|
|
15
|
+
parse(value: unknown): T;
|
|
16
|
+
safeParse(value: unknown): ParseResult<T>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function runtimeClientSchema<T, I>(schema: Schema.Schema<T, I, never>): RuntimeClientSchema<T> {
|
|
20
|
+
const decode = Schema.decodeUnknownSync(schema);
|
|
21
|
+
return {
|
|
22
|
+
parse(value) {
|
|
23
|
+
try {
|
|
24
|
+
return decode(value);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new RuntimeClientSchemaError(error);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
safeParse(value) {
|
|
30
|
+
try {
|
|
31
|
+
return { success: true, data: decode(value) };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return { success: false, error: new RuntimeClientSchemaError(error) };
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const RuntimeHandleEffectSchema = Schema.Struct({
|
|
40
|
+
projectId: Schema.String,
|
|
41
|
+
projectDir: Schema.String,
|
|
42
|
+
configPath: Schema.String,
|
|
43
|
+
statePath: Schema.optional(Schema.String),
|
|
44
|
+
pid: Schema.Int,
|
|
45
|
+
url: Schema.String,
|
|
46
|
+
tokenPath: Schema.String,
|
|
47
|
+
engineVersion: Schema.optional(Schema.String),
|
|
48
|
+
runtimeVersion: Schema.optional(Schema.String),
|
|
49
|
+
startedAt: Schema.optional(Schema.String),
|
|
50
|
+
expiresAt: Schema.optional(Schema.String),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const RuntimeReadyEffectSchema = Schema.Struct({
|
|
54
|
+
type: Schema.Literal("ready"),
|
|
55
|
+
url: Schema.String,
|
|
56
|
+
token: Schema.optional(Schema.String),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const RuntimeHealthEffectSchema = Schema.Struct({
|
|
60
|
+
ok: Schema.Boolean,
|
|
61
|
+
projectId: Schema.String,
|
|
62
|
+
projectDir: Schema.optional(Schema.String),
|
|
63
|
+
configPath: Schema.optional(Schema.String),
|
|
64
|
+
statePath: Schema.optional(Schema.String),
|
|
65
|
+
engineVersion: Schema.optional(Schema.String),
|
|
66
|
+
runtimeVersion: Schema.optional(Schema.String),
|
|
67
|
+
expiresAt: Schema.optional(Schema.String),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const RuntimeMetadataEffectSchema = Schema.Struct({
|
|
71
|
+
apiVersion: Schema.Int,
|
|
72
|
+
engineVersion: Schema.String,
|
|
73
|
+
runtimeVersion: Schema.String,
|
|
74
|
+
protocolHash: Schema.String,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const RuntimeErrorResponseEffectSchema = Schema.Struct({
|
|
78
|
+
error: Schema.Struct({
|
|
79
|
+
message: Schema.String,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export type RuntimeHandle = Schema.Schema.Type<typeof RuntimeHandleEffectSchema>;
|
|
84
|
+
export type RuntimeReady = Schema.Schema.Type<typeof RuntimeReadyEffectSchema>;
|
|
85
|
+
export type RuntimeHealth = Schema.Schema.Type<typeof RuntimeHealthEffectSchema>;
|
|
86
|
+
export type RuntimeMetadata = Schema.Schema.Type<typeof RuntimeMetadataEffectSchema>;
|
|
87
|
+
|
|
88
|
+
export const RuntimeHandleSchema: RuntimeClientSchema<RuntimeHandle> = runtimeClientSchema(RuntimeHandleEffectSchema);
|
|
89
|
+
export const RuntimeReadySchema: RuntimeClientSchema<RuntimeReady> = runtimeClientSchema(RuntimeReadyEffectSchema);
|
|
90
|
+
export const RuntimeHealthSchema: RuntimeClientSchema<RuntimeHealth> = runtimeClientSchema(RuntimeHealthEffectSchema);
|
|
91
|
+
export const RuntimeMetadataSchema: RuntimeClientSchema<RuntimeMetadata> = runtimeClientSchema(RuntimeMetadataEffectSchema);
|
|
92
|
+
export const RuntimeErrorResponseSchema: RuntimeClientSchema<Schema.Schema.Type<typeof RuntimeErrorResponseEffectSchema>> =
|
|
93
|
+
runtimeClientSchema(RuntimeErrorResponseEffectSchema);
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
RuntimeSessionError,
|
|
4
|
+
type RuntimeClientError,
|
|
5
|
+
} from "./errors.ts";
|
|
6
|
+
import { toRuntimeTransportError } from "./http.ts";
|
|
7
|
+
|
|
8
|
+
export type RuntimeSessionHandlers = {
|
|
9
|
+
hello?: RuntimeSessionHello;
|
|
10
|
+
onOpen?(session: RuntimeSessionConnection): Promise<void> | void;
|
|
11
|
+
onMessage(message: unknown, session: RuntimeSessionConnection): Promise<void> | void;
|
|
12
|
+
onClose?(): Promise<void> | void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type RuntimeSessionHello = {
|
|
16
|
+
type: "hello";
|
|
17
|
+
transportVersion: number;
|
|
18
|
+
host: {
|
|
19
|
+
name: string;
|
|
20
|
+
version: string;
|
|
21
|
+
};
|
|
22
|
+
hostMethods: Array<{ id: string; modes?: string[] }>;
|
|
23
|
+
hostCapabilities: Array<{ id: string; schemaHash?: string }>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type RuntimeSessionConnection = {
|
|
27
|
+
send(message: unknown): void;
|
|
28
|
+
close(code?: number, reason?: string): void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function runtimeSessionEffect(
|
|
32
|
+
baseUrl: string,
|
|
33
|
+
token: string,
|
|
34
|
+
path: string,
|
|
35
|
+
handlers: RuntimeSessionHandlers,
|
|
36
|
+
): Effect.Effect<void, RuntimeClientError> {
|
|
37
|
+
return Effect.tryPromise({
|
|
38
|
+
try: () => connectSession(baseUrl, token, path, handlers),
|
|
39
|
+
catch: (cause) => toRuntimeTransportError(cause, { method: "WS", path }),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function connectSession(
|
|
44
|
+
baseUrl: string,
|
|
45
|
+
token: string,
|
|
46
|
+
path: string,
|
|
47
|
+
handlers: RuntimeSessionHandlers,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const url = new URL(path, baseUrl);
|
|
50
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
51
|
+
|
|
52
|
+
return createRuntimeWebSocket(url, token).then((ws) => new Promise((resolvePromise, rejectPromise) => {
|
|
53
|
+
let settled = false;
|
|
54
|
+
|
|
55
|
+
const connection: RuntimeSessionConnection = {
|
|
56
|
+
send(message) {
|
|
57
|
+
ws.send(JSON.stringify(message));
|
|
58
|
+
},
|
|
59
|
+
close(code, reason) {
|
|
60
|
+
ws.close(code, reason);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const settle = (error?: unknown) => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
settled = true;
|
|
67
|
+
if (error) rejectPromise(error);
|
|
68
|
+
else resolvePromise();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
ws.addEventListener("open", async () => {
|
|
72
|
+
try {
|
|
73
|
+
connection.send(handlers.hello ?? defaultHello());
|
|
74
|
+
await handlers.onOpen?.(connection);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
ws.close(1011, error instanceof Error ? error.message : String(error));
|
|
77
|
+
settle(error);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
ws.addEventListener("message", async (event) => {
|
|
81
|
+
try {
|
|
82
|
+
const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
|
|
83
|
+
await handlers.onMessage(data, connection);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
ws.close(1011, error instanceof Error ? error.message : String(error));
|
|
86
|
+
settle(error);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
ws.addEventListener("close", async () => {
|
|
90
|
+
try {
|
|
91
|
+
await handlers.onClose?.();
|
|
92
|
+
settle();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
settle(error);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
ws.addEventListener("error", () => settle(new RuntimeSessionError({ url: url.toString() })));
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type RuntimeWebSocketConstructor = {
|
|
102
|
+
new(url: string | URL, options?: { headers?: Record<string, string> }): WebSocket;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
async function createRuntimeWebSocket(url: URL, token: string): Promise<WebSocket> {
|
|
106
|
+
if (typeof Bun !== "undefined") {
|
|
107
|
+
return new (WebSocket as unknown as RuntimeWebSocketConstructor)(url, {
|
|
108
|
+
headers: { authorization: `Bearer ${token}` },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const ws = await importWebSocketModule("ws") as {
|
|
113
|
+
default?: RuntimeWebSocketConstructor;
|
|
114
|
+
WebSocket?: RuntimeWebSocketConstructor;
|
|
115
|
+
};
|
|
116
|
+
const WebSocketConstructor = ws.default ?? ws.WebSocket;
|
|
117
|
+
if (!WebSocketConstructor) {
|
|
118
|
+
throw new RuntimeSessionError({ url: url.toString(), message: "No WebSocket implementation is available" });
|
|
119
|
+
}
|
|
120
|
+
return new WebSocketConstructor(url, {
|
|
121
|
+
headers: { authorization: `Bearer ${token}` },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const importWebSocketModule = new Function("specifier", "return import(specifier)") as (
|
|
126
|
+
specifier: string,
|
|
127
|
+
) => Promise<unknown>;
|
|
128
|
+
|
|
129
|
+
function defaultHello(): RuntimeSessionHello {
|
|
130
|
+
return {
|
|
131
|
+
type: "hello",
|
|
132
|
+
transportVersion: 1,
|
|
133
|
+
host: {
|
|
134
|
+
name: "rigkit-runtime-client",
|
|
135
|
+
version: "0.0.0",
|
|
136
|
+
},
|
|
137
|
+
hostMethods: [],
|
|
138
|
+
hostCapabilities: [],
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const RIGKIT_RUNTIME_CLIENT_VERSION = "0.1.8";
|