@flamecast/runtime-docker 0.1.1-alpha.432c815
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/dist/index.d.ts +123 -0
- package/dist/index.js +651 -0
- package/dist/request-path.d.ts +1 -0
- package/dist/request-path.js +4 -0
- package/package.json +31 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Runtime } from "@flamecast/protocol/runtime";
|
|
2
|
+
type DockerContainerInspectInfo = {
|
|
3
|
+
Config?: {
|
|
4
|
+
WorkingDir?: string;
|
|
5
|
+
};
|
|
6
|
+
State: {
|
|
7
|
+
Running?: boolean;
|
|
8
|
+
Paused?: boolean;
|
|
9
|
+
ExitCode?: number | null;
|
|
10
|
+
};
|
|
11
|
+
NetworkSettings: {
|
|
12
|
+
Ports: Record<string, Array<{
|
|
13
|
+
HostPort: string;
|
|
14
|
+
}> | null | undefined>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
type DockerExecClient = {
|
|
18
|
+
inspect(): Promise<{
|
|
19
|
+
ExitCode?: number | null;
|
|
20
|
+
}>;
|
|
21
|
+
start(opts: {
|
|
22
|
+
hijack?: boolean;
|
|
23
|
+
stdin?: boolean;
|
|
24
|
+
Detach?: boolean;
|
|
25
|
+
}): Promise<NodeJS.ReadWriteStream>;
|
|
26
|
+
};
|
|
27
|
+
type DockerContainerClient = {
|
|
28
|
+
inspect(): Promise<DockerContainerInspectInfo>;
|
|
29
|
+
start(): Promise<void>;
|
|
30
|
+
unpause(): Promise<void>;
|
|
31
|
+
pause(): Promise<void>;
|
|
32
|
+
kill(): Promise<void>;
|
|
33
|
+
remove(): Promise<void>;
|
|
34
|
+
logs(opts: {
|
|
35
|
+
stdout?: boolean;
|
|
36
|
+
stderr?: boolean;
|
|
37
|
+
tail?: number;
|
|
38
|
+
}): Promise<Buffer>;
|
|
39
|
+
exec(opts: {
|
|
40
|
+
Cmd: string[];
|
|
41
|
+
Env?: string[];
|
|
42
|
+
AttachStdout: boolean;
|
|
43
|
+
AttachStderr: boolean;
|
|
44
|
+
WorkingDir?: string;
|
|
45
|
+
}): Promise<DockerExecClient>;
|
|
46
|
+
putArchive(file: string | Buffer | NodeJS.ReadableStream, options: {
|
|
47
|
+
path: string;
|
|
48
|
+
}): Promise<NodeJS.ReadWriteStream>;
|
|
49
|
+
};
|
|
50
|
+
type DockerCreatedContainerClient = DockerContainerClient & {
|
|
51
|
+
id: string;
|
|
52
|
+
};
|
|
53
|
+
type DockerImageClient = {
|
|
54
|
+
inspect(): Promise<unknown>;
|
|
55
|
+
};
|
|
56
|
+
type DockerClient = {
|
|
57
|
+
createContainer(opts: Record<string, unknown>): Promise<DockerCreatedContainerClient>;
|
|
58
|
+
getContainer(id: string): DockerContainerClient;
|
|
59
|
+
getImage(image: string): DockerImageClient;
|
|
60
|
+
listContainers(opts: {
|
|
61
|
+
all: boolean;
|
|
62
|
+
}): Promise<Array<{
|
|
63
|
+
Id: string;
|
|
64
|
+
Labels?: Record<string, string>;
|
|
65
|
+
}>>;
|
|
66
|
+
pull(image: string): Promise<NodeJS.ReadableStream>;
|
|
67
|
+
modem: {
|
|
68
|
+
demuxStream(stream: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream): void;
|
|
69
|
+
followProgress(stream: NodeJS.ReadableStream, onFinished: (err: Error | null) => void): void;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* DockerRuntime — one Docker container per runtime instance.
|
|
74
|
+
*
|
|
75
|
+
* When `start(instanceId)` is called, a container is created with the base
|
|
76
|
+
* image and a single runtime-host process is started on a fixed port. Sessions
|
|
77
|
+
* are managed by the runtime-host via its multi-session HTTP and WebSocket API.
|
|
78
|
+
*
|
|
79
|
+
* `pause(instanceId)` freezes the container (and the runtime-host inside it).
|
|
80
|
+
* `stop(instanceId)` tears down the container entirely.
|
|
81
|
+
*/
|
|
82
|
+
export declare class DockerRuntime implements Runtime {
|
|
83
|
+
private readonly baseImage;
|
|
84
|
+
private readonly docker;
|
|
85
|
+
private readonly runtimeHostPort;
|
|
86
|
+
private readonly workingDir;
|
|
87
|
+
/** instanceName → Docker container + runtime-host port */
|
|
88
|
+
private readonly instances;
|
|
89
|
+
/** sessionId → which instance it belongs to */
|
|
90
|
+
private readonly sessions;
|
|
91
|
+
constructor(opts?: {
|
|
92
|
+
baseImage?: string;
|
|
93
|
+
docker?: DockerClient;
|
|
94
|
+
runtimeHostPort?: number;
|
|
95
|
+
/** Working directory inside the container. Defaults to `/workspace`. */
|
|
96
|
+
cwd?: string;
|
|
97
|
+
});
|
|
98
|
+
start(instanceId: string): Promise<void>;
|
|
99
|
+
stop(instanceId: string): Promise<void>;
|
|
100
|
+
pause(instanceId: string): Promise<void>;
|
|
101
|
+
getInstanceStatus(instanceId: string): Promise<"running" | "stopped" | "paused" | undefined>;
|
|
102
|
+
getWebsocketUrl(instanceId: string): string | undefined;
|
|
103
|
+
fetchSession(sessionId: string, request: Request): Promise<Response>;
|
|
104
|
+
fetchInstance(instanceId: string, request: Request): Promise<Response>;
|
|
105
|
+
getRuntimeMeta(_sessionId: string): Record<string, unknown> | null;
|
|
106
|
+
reconnect(sessionId: string, runtimeMeta: Record<string, unknown> | null): Promise<boolean>;
|
|
107
|
+
dispose(): Promise<void>;
|
|
108
|
+
private handleStart;
|
|
109
|
+
private proxySessionRequest;
|
|
110
|
+
private getInstanceForSession;
|
|
111
|
+
private handleInstanceSnapshot;
|
|
112
|
+
private handleInstanceFilePreview;
|
|
113
|
+
private getRunningContainer;
|
|
114
|
+
private execInContainer;
|
|
115
|
+
private bootstrapContainer;
|
|
116
|
+
private extractHostPort;
|
|
117
|
+
private inspectContainer;
|
|
118
|
+
private resolveInstanceContainer;
|
|
119
|
+
/** Pull the image if it doesn't exist locally. */
|
|
120
|
+
private ensureImage;
|
|
121
|
+
private waitForReady;
|
|
122
|
+
}
|
|
123
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
4
|
+
import Docker from "dockerode";
|
|
5
|
+
import { resolveSessionHostBinary as resolveSessionHostBinaryShared } from "@flamecast/session-host-go/resolve";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Session-host binary resolution
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/** Resolve the session-host binary (host architecture). Throws if not found. */
|
|
10
|
+
function resolveSessionHostBinary() {
|
|
11
|
+
const binary = resolveSessionHostBinaryShared();
|
|
12
|
+
if (!binary) {
|
|
13
|
+
throw new Error("No session-host binary found. Install Go and run: " +
|
|
14
|
+
"pnpm --filter @flamecast/session-host-go run postinstall");
|
|
15
|
+
}
|
|
16
|
+
return binary;
|
|
17
|
+
}
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const CONTAINER_BIN_PATH = "/usr/local/bin/session-host";
|
|
22
|
+
const DEFAULT_CONTAINER_WORKSPACE = "/workspace";
|
|
23
|
+
const JSON_HEADERS = { "Content-Type": "application/json" };
|
|
24
|
+
const DEFAULT_RUNTIME_HOST_PORT = 9000;
|
|
25
|
+
const FLAMECAST_INSTANCE_LABEL = "flamecast.instance";
|
|
26
|
+
function jsonResponse(body, status = 200) {
|
|
27
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
28
|
+
}
|
|
29
|
+
function globToRegexSource(pattern) {
|
|
30
|
+
let source = "";
|
|
31
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
32
|
+
const char = pattern[index];
|
|
33
|
+
if (char === "*") {
|
|
34
|
+
if (pattern[index + 1] === "*") {
|
|
35
|
+
source += ".*";
|
|
36
|
+
index += 1;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
source += "[^/]*";
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (char === "?") {
|
|
44
|
+
source += "[^/]";
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if ("\\^$+?.()|{}[]".includes(char)) {
|
|
48
|
+
source += `\\${char}`;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
source += char;
|
|
52
|
+
}
|
|
53
|
+
return source;
|
|
54
|
+
}
|
|
55
|
+
function parseGitIgnoreRule(line) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
58
|
+
return null;
|
|
59
|
+
const literal = trimmed.startsWith("\\#") || trimmed.startsWith("\\!");
|
|
60
|
+
const negated = !literal && trimmed.startsWith("!");
|
|
61
|
+
const rawPattern = negated ? trimmed.slice(1) : literal ? trimmed.slice(1) : trimmed;
|
|
62
|
+
if (!rawPattern)
|
|
63
|
+
return null;
|
|
64
|
+
const directoryOnly = rawPattern.endsWith("/");
|
|
65
|
+
const anchored = rawPattern.startsWith("/");
|
|
66
|
+
const normalized = rawPattern.slice(anchored ? 1 : 0, directoryOnly ? -1 : undefined);
|
|
67
|
+
if (!normalized)
|
|
68
|
+
return null;
|
|
69
|
+
const hasSlash = normalized.includes("/");
|
|
70
|
+
const source = globToRegexSource(normalized);
|
|
71
|
+
const regex = !hasSlash
|
|
72
|
+
? new RegExp(directoryOnly ? `(^|/)${source}(/|$)` : `(^|/)${source}$`, "u")
|
|
73
|
+
: anchored
|
|
74
|
+
? new RegExp(directoryOnly ? `^${source}(/|$)` : `^${source}$`, "u")
|
|
75
|
+
: new RegExp(directoryOnly ? `(^|.*/)${source}(/|$)` : `(^|.*/)${source}$`, "u");
|
|
76
|
+
return { negated, regex };
|
|
77
|
+
}
|
|
78
|
+
function parseGitIgnoreRules(content) {
|
|
79
|
+
const rules = [parseGitIgnoreRule(".git/")].filter((rule) => rule !== null);
|
|
80
|
+
const extra = process.env.FILE_WATCHER_IGNORE;
|
|
81
|
+
if (extra) {
|
|
82
|
+
for (const pattern of extra.split(",")) {
|
|
83
|
+
const rule = parseGitIgnoreRule(pattern.trim());
|
|
84
|
+
if (rule)
|
|
85
|
+
rules.push(rule);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const line of content.split(/\r?\n/u)) {
|
|
89
|
+
const rule = parseGitIgnoreRule(line);
|
|
90
|
+
if (rule)
|
|
91
|
+
rules.push(rule);
|
|
92
|
+
}
|
|
93
|
+
return rules;
|
|
94
|
+
}
|
|
95
|
+
function isGitIgnored(path, rules) {
|
|
96
|
+
let ignored = false;
|
|
97
|
+
for (const rule of rules) {
|
|
98
|
+
if (rule.regex.test(path)) {
|
|
99
|
+
ignored = !rule.negated;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return ignored;
|
|
103
|
+
}
|
|
104
|
+
function parseFindOutput(stdout) {
|
|
105
|
+
return stdout
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((line) => line.trimEnd())
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.flatMap((line) => {
|
|
110
|
+
const [kind, path] = line.split("\t", 2);
|
|
111
|
+
if (!kind || !path)
|
|
112
|
+
return [];
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
path,
|
|
116
|
+
type: kind === "d" ? "directory" : kind === "f" ? "file" : kind === "l" ? "symlink" : "other",
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function shellEscape(value) {
|
|
122
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
123
|
+
}
|
|
124
|
+
function resolveWorkspacePath(workspaceRoot, filePath) {
|
|
125
|
+
if (!filePath || filePath.includes("\0"))
|
|
126
|
+
return null;
|
|
127
|
+
const normalized = posix.normalize(filePath);
|
|
128
|
+
if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return posix.join(workspaceRoot, normalized);
|
|
132
|
+
}
|
|
133
|
+
function resolveContainerWorkspaceRoot(info) {
|
|
134
|
+
const configured = info.Config?.WorkingDir?.trim();
|
|
135
|
+
if (!configured)
|
|
136
|
+
return DEFAULT_CONTAINER_WORKSPACE;
|
|
137
|
+
const normalized = posix.normalize(configured);
|
|
138
|
+
if (normalized === "." || normalized === "")
|
|
139
|
+
return DEFAULT_CONTAINER_WORKSPACE;
|
|
140
|
+
return normalized.startsWith("/") ? normalized : posix.join("/", normalized);
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// DockerRuntime
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
/**
|
|
146
|
+
* DockerRuntime — one Docker container per runtime instance.
|
|
147
|
+
*
|
|
148
|
+
* When `start(instanceId)` is called, a container is created with the base
|
|
149
|
+
* image and a single runtime-host process is started on a fixed port. Sessions
|
|
150
|
+
* are managed by the runtime-host via its multi-session HTTP and WebSocket API.
|
|
151
|
+
*
|
|
152
|
+
* `pause(instanceId)` freezes the container (and the runtime-host inside it).
|
|
153
|
+
* `stop(instanceId)` tears down the container entirely.
|
|
154
|
+
*/
|
|
155
|
+
export class DockerRuntime {
|
|
156
|
+
baseImage;
|
|
157
|
+
docker;
|
|
158
|
+
runtimeHostPort;
|
|
159
|
+
workingDir;
|
|
160
|
+
/** instanceName → Docker container + runtime-host port */
|
|
161
|
+
instances = new Map();
|
|
162
|
+
/** sessionId → which instance it belongs to */
|
|
163
|
+
sessions = new Map();
|
|
164
|
+
constructor(opts) {
|
|
165
|
+
this.baseImage = opts?.baseImage ?? "node:22-slim";
|
|
166
|
+
this.docker = opts?.docker ?? new Docker();
|
|
167
|
+
this.runtimeHostPort = opts?.runtimeHostPort ?? DEFAULT_RUNTIME_HOST_PORT;
|
|
168
|
+
this.workingDir = opts?.cwd ?? DEFAULT_CONTAINER_WORKSPACE;
|
|
169
|
+
}
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Instance lifecycle
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
async start(instanceId) {
|
|
174
|
+
const existing = await this.resolveInstanceContainer(instanceId);
|
|
175
|
+
if (existing) {
|
|
176
|
+
// Resume a paused or stopped container
|
|
177
|
+
if (existing.info.State.Paused) {
|
|
178
|
+
await existing.container.unpause();
|
|
179
|
+
}
|
|
180
|
+
else if (!existing.info.State.Running) {
|
|
181
|
+
await existing.container.start();
|
|
182
|
+
}
|
|
183
|
+
await this.inspectContainer(instanceId, existing.entry.containerId);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Create a new container for this instance
|
|
187
|
+
const binaryPath = resolveSessionHostBinary();
|
|
188
|
+
await this.ensureImage(this.baseImage);
|
|
189
|
+
const portKey = `${this.runtimeHostPort}/tcp`;
|
|
190
|
+
const container = await this.docker.createContainer({
|
|
191
|
+
Image: this.baseImage,
|
|
192
|
+
Cmd: ["tail", "-f", "/dev/null"],
|
|
193
|
+
ExposedPorts: { [portKey]: {} },
|
|
194
|
+
Env: ["RUNTIME_SETUP_ENABLED=1"],
|
|
195
|
+
HostConfig: {
|
|
196
|
+
PortBindings: { [portKey]: [{ HostPort: "0" }] },
|
|
197
|
+
},
|
|
198
|
+
WorkingDir: this.workingDir,
|
|
199
|
+
Labels: { [FLAMECAST_INSTANCE_LABEL]: instanceId },
|
|
200
|
+
});
|
|
201
|
+
await container.start();
|
|
202
|
+
let info = await container.inspect();
|
|
203
|
+
if (!info.State.Running) {
|
|
204
|
+
const logs = await container.logs({ stdout: true, stderr: true, tail: 20 });
|
|
205
|
+
await container.remove().catch(() => { });
|
|
206
|
+
throw new Error(`Container exited immediately (code=${info.State.ExitCode}). Logs:\n${logs.toString()}`);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const workspaceRoot = resolveContainerWorkspaceRoot(info);
|
|
210
|
+
await this.bootstrapContainer(container, binaryPath, workspaceRoot);
|
|
211
|
+
// Start the single runtime-host process
|
|
212
|
+
const exec = await container.exec({
|
|
213
|
+
Cmd: [CONTAINER_BIN_PATH],
|
|
214
|
+
Env: [`SESSION_HOST_PORT=${this.runtimeHostPort}`],
|
|
215
|
+
AttachStdout: false,
|
|
216
|
+
AttachStderr: false,
|
|
217
|
+
WorkingDir: workspaceRoot,
|
|
218
|
+
});
|
|
219
|
+
await exec.start({ Detach: true });
|
|
220
|
+
info = await container.inspect();
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
await container.kill().catch(() => { });
|
|
224
|
+
await container.remove().catch(() => { });
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
const hostPort = this.extractHostPort(info);
|
|
228
|
+
this.instances.set(instanceId, {
|
|
229
|
+
containerId: container.id,
|
|
230
|
+
runtimeHostPort: this.runtimeHostPort,
|
|
231
|
+
hostPort,
|
|
232
|
+
});
|
|
233
|
+
// Wait for the runtime-host to be ready
|
|
234
|
+
await this.waitForReady(hostPort);
|
|
235
|
+
console.log(`[DockerRuntime] Instance "${instanceId}" started (container=${container.id.slice(0, 12)}, port=${hostPort})`);
|
|
236
|
+
}
|
|
237
|
+
async stop(instanceId) {
|
|
238
|
+
// Clean up session tracking for this instance
|
|
239
|
+
for (const [sid, session] of this.sessions) {
|
|
240
|
+
if (session.instanceName === instanceId) {
|
|
241
|
+
this.sessions.delete(sid);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const resolved = await this.resolveInstanceContainer(instanceId);
|
|
245
|
+
if (!resolved)
|
|
246
|
+
return;
|
|
247
|
+
try {
|
|
248
|
+
await resolved.container.kill().catch(() => { });
|
|
249
|
+
await resolved.container.remove().catch(() => { });
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Container may already be gone
|
|
253
|
+
}
|
|
254
|
+
this.instances.delete(instanceId);
|
|
255
|
+
console.log(`[DockerRuntime] Instance "${instanceId}" stopped`);
|
|
256
|
+
}
|
|
257
|
+
async pause(instanceId) {
|
|
258
|
+
const resolved = await this.resolveInstanceContainer(instanceId);
|
|
259
|
+
if (!resolved)
|
|
260
|
+
throw new Error(`Instance "${instanceId}" not found`);
|
|
261
|
+
await resolved.container.pause();
|
|
262
|
+
console.log(`[DockerRuntime] Instance "${instanceId}" paused`);
|
|
263
|
+
}
|
|
264
|
+
async getInstanceStatus(instanceId) {
|
|
265
|
+
const resolved = await this.resolveInstanceContainer(instanceId);
|
|
266
|
+
if (!resolved)
|
|
267
|
+
return undefined;
|
|
268
|
+
if (resolved.info.State.Paused)
|
|
269
|
+
return "paused";
|
|
270
|
+
if (resolved.info.State.Running)
|
|
271
|
+
return "running";
|
|
272
|
+
return "stopped";
|
|
273
|
+
}
|
|
274
|
+
getWebsocketUrl(instanceId) {
|
|
275
|
+
const entry = this.instances.get(instanceId);
|
|
276
|
+
if (!entry)
|
|
277
|
+
return undefined;
|
|
278
|
+
return `ws://localhost:${entry.hostPort}`;
|
|
279
|
+
}
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Session handling — route to the single runtime-host
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
async fetchSession(sessionId, request) {
|
|
284
|
+
const url = new URL(request.url);
|
|
285
|
+
const pathWithQuery = `${url.pathname}${url.search}`;
|
|
286
|
+
if (url.pathname.endsWith("/start") && request.method === "POST") {
|
|
287
|
+
return this.handleStart(sessionId, request);
|
|
288
|
+
}
|
|
289
|
+
// All other session requests proxy to /sessions/{sessionId}{path}
|
|
290
|
+
return this.proxySessionRequest(sessionId, pathWithQuery, request);
|
|
291
|
+
}
|
|
292
|
+
async fetchInstance(instanceId, request) {
|
|
293
|
+
const path = new URL(request.url).pathname;
|
|
294
|
+
if (path === "/fs/snapshot" && request.method === "GET") {
|
|
295
|
+
return this.handleInstanceSnapshot(instanceId, request);
|
|
296
|
+
}
|
|
297
|
+
if (path === "/files" && request.method === "GET") {
|
|
298
|
+
return this.handleInstanceFilePreview(instanceId, request);
|
|
299
|
+
}
|
|
300
|
+
return jsonResponse({ error: `Unsupported runtime request: ${request.method} ${path}` }, 404);
|
|
301
|
+
}
|
|
302
|
+
getRuntimeMeta(_sessionId) {
|
|
303
|
+
// Find which instance this session belongs to by checking runtime-host health
|
|
304
|
+
// For now, return the first instance (most common case: one instance)
|
|
305
|
+
for (const [instanceName, entry] of this.instances) {
|
|
306
|
+
return {
|
|
307
|
+
instanceName,
|
|
308
|
+
containerId: entry.containerId,
|
|
309
|
+
hostPort: entry.hostPort,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
async reconnect(sessionId, runtimeMeta) {
|
|
315
|
+
if (!runtimeMeta)
|
|
316
|
+
return false;
|
|
317
|
+
const instanceName = typeof runtimeMeta.instanceName === "string" ? runtimeMeta.instanceName : undefined;
|
|
318
|
+
const containerId = typeof runtimeMeta.containerId === "string" ? runtimeMeta.containerId : undefined;
|
|
319
|
+
const hostPort = typeof runtimeMeta.hostPort === "number" ? runtimeMeta.hostPort : undefined;
|
|
320
|
+
if (!instanceName || !containerId || !hostPort)
|
|
321
|
+
return false;
|
|
322
|
+
try {
|
|
323
|
+
// Ensure instance is tracked
|
|
324
|
+
if (!this.instances.has(instanceName)) {
|
|
325
|
+
const container = this.docker.getContainer(containerId);
|
|
326
|
+
const info = await container.inspect();
|
|
327
|
+
if (!info.State.Running && !info.State.Paused)
|
|
328
|
+
return false;
|
|
329
|
+
this.instances.set(instanceName, {
|
|
330
|
+
containerId,
|
|
331
|
+
runtimeHostPort: this.runtimeHostPort,
|
|
332
|
+
hostPort,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
// Verify the runtime-host is responsive and the session exists
|
|
336
|
+
const resp = await fetch(`http://localhost:${hostPort}/sessions/${sessionId}/health`).catch(() => null);
|
|
337
|
+
if (!resp?.ok)
|
|
338
|
+
return false;
|
|
339
|
+
this.sessions.set(sessionId, { instanceName });
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async dispose() {
|
|
347
|
+
const instanceNames = [...this.instances.keys()];
|
|
348
|
+
await Promise.allSettled(instanceNames.map((name) => this.stop(name)));
|
|
349
|
+
this.instances.clear();
|
|
350
|
+
this.sessions.clear();
|
|
351
|
+
}
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Request handlers
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
async handleStart(sessionId, request) {
|
|
356
|
+
try {
|
|
357
|
+
const parsed = JSON.parse(await request.text());
|
|
358
|
+
const instanceName = typeof parsed.instanceName === "string" ? parsed.instanceName : undefined;
|
|
359
|
+
if (!instanceName) {
|
|
360
|
+
return jsonResponse({ error: "Missing instanceName — create a runtime instance first" }, 400);
|
|
361
|
+
}
|
|
362
|
+
const resolved = await this.resolveInstanceContainer(instanceName);
|
|
363
|
+
if (!resolved) {
|
|
364
|
+
return jsonResponse({ error: `Runtime instance "${instanceName}" not found` }, 404);
|
|
365
|
+
}
|
|
366
|
+
const workspaceRoot = resolveContainerWorkspaceRoot(resolved.info);
|
|
367
|
+
// Forward to runtime-host at /sessions/{sessionId}/start
|
|
368
|
+
parsed.workspace = workspaceRoot;
|
|
369
|
+
delete parsed.instanceName;
|
|
370
|
+
const resp = await fetch(`http://localhost:${resolved.entry.hostPort}/sessions/${sessionId}/start`, {
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers: JSON_HEADERS,
|
|
373
|
+
body: JSON.stringify(parsed),
|
|
374
|
+
});
|
|
375
|
+
const text = await resp.text();
|
|
376
|
+
let result;
|
|
377
|
+
try {
|
|
378
|
+
result = JSON.parse(text);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
throw new Error(`RuntimeHost /sessions/${sessionId}/start failed (${resp.status}): ${text}`);
|
|
382
|
+
}
|
|
383
|
+
if (!resp.ok) {
|
|
384
|
+
throw new Error(`RuntimeHost /sessions/${sessionId}/start failed (${resp.status}): ${result.error ?? text}`);
|
|
385
|
+
}
|
|
386
|
+
// Track session → instance mapping
|
|
387
|
+
this.sessions.set(sessionId, { instanceName });
|
|
388
|
+
// Inject the host-visible URLs (shared across all sessions on this instance)
|
|
389
|
+
result.hostUrl = `http://localhost:${resolved.entry.hostPort}`;
|
|
390
|
+
result.websocketUrl = `ws://localhost:${resolved.entry.hostPort}`;
|
|
391
|
+
return new Response(JSON.stringify(result), {
|
|
392
|
+
status: resp.status,
|
|
393
|
+
headers: JSON_HEADERS,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
return jsonResponse({ error: err instanceof Error ? err.message : "Failed to start session" }, 500);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async proxySessionRequest(sessionId, path, request) {
|
|
401
|
+
const entry = this.getInstanceForSession(sessionId);
|
|
402
|
+
if (!entry) {
|
|
403
|
+
return jsonResponse({ error: `Session "${sessionId}" not found` }, 404);
|
|
404
|
+
}
|
|
405
|
+
const body = request.method !== "GET" ? await request.text() : undefined;
|
|
406
|
+
const resp = await fetch(`http://localhost:${entry.hostPort}/sessions/${sessionId}${path}`, {
|
|
407
|
+
method: request.method,
|
|
408
|
+
headers: JSON_HEADERS,
|
|
409
|
+
body,
|
|
410
|
+
});
|
|
411
|
+
return new Response(await resp.text(), {
|
|
412
|
+
status: resp.status,
|
|
413
|
+
headers: JSON_HEADERS,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
getInstanceForSession(sessionId) {
|
|
417
|
+
const session = this.sessions.get(sessionId);
|
|
418
|
+
if (!session)
|
|
419
|
+
return null;
|
|
420
|
+
return this.instances.get(session.instanceName) ?? null;
|
|
421
|
+
}
|
|
422
|
+
async handleInstanceSnapshot(instanceId, request) {
|
|
423
|
+
const resolved = await this.getRunningContainer(instanceId);
|
|
424
|
+
if (resolved instanceof Response)
|
|
425
|
+
return resolved;
|
|
426
|
+
const url = new URL(request.url);
|
|
427
|
+
const showAllFiles = url.searchParams.get("showAllFiles") === "true";
|
|
428
|
+
const workspaceRoot = resolveContainerWorkspaceRoot(resolved.info);
|
|
429
|
+
const requestedPath = url.searchParams.get("path");
|
|
430
|
+
const targetDir = requestedPath ? posix.resolve(requestedPath) : workspaceRoot;
|
|
431
|
+
const listResult = await this.execInContainer(resolved.container, [
|
|
432
|
+
"sh",
|
|
433
|
+
"-lc",
|
|
434
|
+
`find -L ${shellEscape(targetDir)} -mindepth 1 -maxdepth 1 -printf '%y\t%f\n'`,
|
|
435
|
+
]);
|
|
436
|
+
if (listResult.exitCode !== 0) {
|
|
437
|
+
return jsonResponse({ error: listResult.stderr.trim() || "Failed to read runtime filesystem" }, 500);
|
|
438
|
+
}
|
|
439
|
+
let entries = parseFindOutput(listResult.stdout);
|
|
440
|
+
if (!showAllFiles) {
|
|
441
|
+
// Apply .gitignore rules from the directory being listed
|
|
442
|
+
const gitIgnoreResult = await this.execInContainer(resolved.container, [
|
|
443
|
+
"sh",
|
|
444
|
+
"-lc",
|
|
445
|
+
`cat ${shellEscape(posix.join(targetDir, ".gitignore"))}`,
|
|
446
|
+
]);
|
|
447
|
+
const rules = parseGitIgnoreRules(gitIgnoreResult.exitCode === 0 ? gitIgnoreResult.stdout : "");
|
|
448
|
+
if (rules.length > 0) {
|
|
449
|
+
entries = entries.filter((entry) => !isGitIgnored(entry.path, rules));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const maxEntries = 10_000;
|
|
453
|
+
const truncated = entries.length > maxEntries;
|
|
454
|
+
return jsonResponse({
|
|
455
|
+
root: workspaceRoot,
|
|
456
|
+
path: targetDir,
|
|
457
|
+
entries: truncated ? entries.slice(0, maxEntries) : entries,
|
|
458
|
+
truncated,
|
|
459
|
+
maxEntries,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
async handleInstanceFilePreview(instanceId, request) {
|
|
463
|
+
const resolved = await this.getRunningContainer(instanceId);
|
|
464
|
+
if (resolved instanceof Response)
|
|
465
|
+
return resolved;
|
|
466
|
+
const filePath = new URL(request.url).searchParams.get("path");
|
|
467
|
+
if (!filePath) {
|
|
468
|
+
return jsonResponse({ error: "Missing ?path= parameter" }, 400);
|
|
469
|
+
}
|
|
470
|
+
const workspaceRoot = resolveContainerWorkspaceRoot(resolved.info);
|
|
471
|
+
const resolvedPath = resolveWorkspacePath(workspaceRoot, filePath);
|
|
472
|
+
if (!resolvedPath) {
|
|
473
|
+
return jsonResponse({ error: "Path outside workspace" }, 403);
|
|
474
|
+
}
|
|
475
|
+
const sizeResult = await this.execInContainer(resolved.container, [
|
|
476
|
+
"sh",
|
|
477
|
+
"-lc",
|
|
478
|
+
`wc -c < ${shellEscape(resolvedPath)}`,
|
|
479
|
+
]);
|
|
480
|
+
if (sizeResult.exitCode !== 0) {
|
|
481
|
+
return jsonResponse({ error: `Cannot read: ${filePath}` }, 404);
|
|
482
|
+
}
|
|
483
|
+
const contentResult = await this.execInContainer(resolved.container, [
|
|
484
|
+
"sh",
|
|
485
|
+
"-lc",
|
|
486
|
+
`head -c 100000 ${shellEscape(resolvedPath)}`,
|
|
487
|
+
]);
|
|
488
|
+
if (contentResult.exitCode !== 0) {
|
|
489
|
+
return jsonResponse({ error: `Cannot read: ${filePath}` }, 404);
|
|
490
|
+
}
|
|
491
|
+
const maxChars = 100_000;
|
|
492
|
+
const rawSize = Number.parseInt(sizeResult.stdout.trim(), 10);
|
|
493
|
+
const truncated = Number.isFinite(rawSize) ? rawSize > maxChars : false;
|
|
494
|
+
return jsonResponse({
|
|
495
|
+
path: filePath,
|
|
496
|
+
content: contentResult.stdout,
|
|
497
|
+
truncated,
|
|
498
|
+
maxChars,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
async getRunningContainer(instanceId) {
|
|
502
|
+
const resolved = await this.resolveInstanceContainer(instanceId);
|
|
503
|
+
if (!resolved) {
|
|
504
|
+
return jsonResponse({ error: `Runtime instance "${instanceId}" not found` }, 404);
|
|
505
|
+
}
|
|
506
|
+
if (!resolved.info.State.Running || resolved.info.State.Paused) {
|
|
507
|
+
return jsonResponse({ error: `Runtime instance "${instanceId}" is not running` }, 409);
|
|
508
|
+
}
|
|
509
|
+
return { container: resolved.container, info: resolved.info };
|
|
510
|
+
}
|
|
511
|
+
async execInContainer(container, cmd) {
|
|
512
|
+
const exec = await container.exec({
|
|
513
|
+
Cmd: cmd,
|
|
514
|
+
AttachStdout: true,
|
|
515
|
+
AttachStderr: true,
|
|
516
|
+
});
|
|
517
|
+
const stream = await exec.start({ hijack: true, stdin: false });
|
|
518
|
+
const stdout = new PassThrough();
|
|
519
|
+
const stderr = new PassThrough();
|
|
520
|
+
let stdoutText = "";
|
|
521
|
+
let stderrText = "";
|
|
522
|
+
stdout.on("data", (chunk) => {
|
|
523
|
+
stdoutText += chunk.toString("utf8");
|
|
524
|
+
});
|
|
525
|
+
stderr.on("data", (chunk) => {
|
|
526
|
+
stderrText += chunk.toString("utf8");
|
|
527
|
+
});
|
|
528
|
+
this.docker.modem.demuxStream(stream, stdout, stderr);
|
|
529
|
+
await new Promise((resolve, reject) => {
|
|
530
|
+
stream.on("end", () => resolve());
|
|
531
|
+
stream.on("error", reject);
|
|
532
|
+
});
|
|
533
|
+
const result = await exec.inspect();
|
|
534
|
+
return {
|
|
535
|
+
exitCode: result.ExitCode ?? 1,
|
|
536
|
+
stdout: stdoutText,
|
|
537
|
+
stderr: stderrText,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
async bootstrapContainer(container, binaryPath, workspaceRoot) {
|
|
541
|
+
await container.putArchive(createTarArchive("session-host", readFileSync(binaryPath), 0o755), {
|
|
542
|
+
path: posix.dirname(CONTAINER_BIN_PATH),
|
|
543
|
+
});
|
|
544
|
+
const prepareResult = await this.execInContainer(container, [
|
|
545
|
+
"sh",
|
|
546
|
+
"-lc",
|
|
547
|
+
`mkdir -p ${shellEscape(workspaceRoot)} && chmod +x ${shellEscape(CONTAINER_BIN_PATH)}`,
|
|
548
|
+
]);
|
|
549
|
+
if (prepareResult.exitCode !== 0) {
|
|
550
|
+
throw new Error(prepareResult.stderr.trim() || "Failed to bootstrap Docker runtime");
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
extractHostPort(info) {
|
|
554
|
+
const binding = info.NetworkSettings.Ports[`${this.runtimeHostPort}/tcp`];
|
|
555
|
+
const hostPort = parseInt(binding?.[0]?.HostPort ?? "0", 10);
|
|
556
|
+
if (!hostPort) {
|
|
557
|
+
throw new Error("Failed to extract host port for runtime-host");
|
|
558
|
+
}
|
|
559
|
+
return hostPort;
|
|
560
|
+
}
|
|
561
|
+
async inspectContainer(instanceId, containerId) {
|
|
562
|
+
const container = this.docker.getContainer(containerId);
|
|
563
|
+
const info = await container.inspect().catch(() => null);
|
|
564
|
+
if (!info)
|
|
565
|
+
return null;
|
|
566
|
+
const hostPort = this.extractHostPort(info);
|
|
567
|
+
const entry = {
|
|
568
|
+
containerId,
|
|
569
|
+
runtimeHostPort: this.runtimeHostPort,
|
|
570
|
+
hostPort,
|
|
571
|
+
};
|
|
572
|
+
this.instances.set(instanceId, entry);
|
|
573
|
+
return { container, info, entry };
|
|
574
|
+
}
|
|
575
|
+
async resolveInstanceContainer(instanceId) {
|
|
576
|
+
const tracked = this.instances.get(instanceId);
|
|
577
|
+
if (tracked) {
|
|
578
|
+
const resolved = await this.inspectContainer(instanceId, tracked.containerId);
|
|
579
|
+
if (resolved)
|
|
580
|
+
return resolved;
|
|
581
|
+
this.instances.delete(instanceId);
|
|
582
|
+
}
|
|
583
|
+
const containers = await this.docker.listContainers({ all: true });
|
|
584
|
+
const match = containers.find((container) => container.Labels?.[FLAMECAST_INSTANCE_LABEL] === instanceId);
|
|
585
|
+
if (!match?.Id)
|
|
586
|
+
return null;
|
|
587
|
+
return this.inspectContainer(instanceId, match.Id);
|
|
588
|
+
}
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
// Image management
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
/** Pull the image if it doesn't exist locally. */
|
|
593
|
+
async ensureImage(image) {
|
|
594
|
+
try {
|
|
595
|
+
await this.docker.getImage(image).inspect();
|
|
596
|
+
return; // Already available
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// Not found locally — pull it
|
|
600
|
+
}
|
|
601
|
+
console.log(`[DockerRuntime] Pulling image ${image}...`);
|
|
602
|
+
const stream = await this.docker.pull(image);
|
|
603
|
+
await new Promise((resolve, reject) => {
|
|
604
|
+
this.docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
// Readiness check
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
async waitForReady(port, timeoutMs = 30_000) {
|
|
611
|
+
const deadline = Date.now() + timeoutMs;
|
|
612
|
+
while (Date.now() < deadline) {
|
|
613
|
+
try {
|
|
614
|
+
const resp = await fetch(`http://localhost:${port}/health`);
|
|
615
|
+
if (resp.ok)
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
// Not ready yet
|
|
620
|
+
}
|
|
621
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
622
|
+
}
|
|
623
|
+
throw new Error(`RuntimeHost not ready after ${timeoutMs}ms`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function createTarArchive(fileName, contents, mode) {
|
|
627
|
+
const header = Buffer.alloc(512, 0);
|
|
628
|
+
writeTarString(header, fileName, 0, 100);
|
|
629
|
+
writeTarOctal(header, mode, 100, 8);
|
|
630
|
+
writeTarOctal(header, 0, 108, 8);
|
|
631
|
+
writeTarOctal(header, 0, 116, 8);
|
|
632
|
+
writeTarOctal(header, contents.length, 124, 12);
|
|
633
|
+
writeTarOctal(header, Math.floor(Date.now() / 1000), 136, 12);
|
|
634
|
+
header.fill(0x20, 148, 156);
|
|
635
|
+
header[156] = "0".charCodeAt(0);
|
|
636
|
+
writeTarString(header, "ustar", 257, 6);
|
|
637
|
+
writeTarString(header, "00", 263, 2);
|
|
638
|
+
let checksum = 0;
|
|
639
|
+
for (const byte of header)
|
|
640
|
+
checksum += byte;
|
|
641
|
+
Buffer.from(`${checksum.toString(8).padStart(6, "0")}\0 `, "ascii").copy(header, 148);
|
|
642
|
+
const padding = (512 - (contents.length % 512)) % 512;
|
|
643
|
+
return Buffer.concat([header, contents, Buffer.alloc(padding), Buffer.alloc(1024)]);
|
|
644
|
+
}
|
|
645
|
+
function writeTarOctal(header, value, offset, width) {
|
|
646
|
+
const encoded = `${value.toString(8).padStart(width - 1, "0")}\0`;
|
|
647
|
+
Buffer.from(encoded, "ascii").copy(header, offset, 0, width);
|
|
648
|
+
}
|
|
649
|
+
function writeTarString(header, value, offset, width) {
|
|
650
|
+
Buffer.from(value, "ascii").copy(header, offset, 0, width);
|
|
651
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getRequestPath(request: Request): string;
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flamecast/runtime-docker",
|
|
3
|
+
"version": "0.1.1-alpha.432c815",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist"
|
|
6
|
+
],
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"dockerode": "^4.0.6",
|
|
19
|
+
"@flamecast/protocol": "0.1.1-alpha.432c815",
|
|
20
|
+
"@flamecast/session-host-go": "0.1.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/dockerode": "^3.3.37",
|
|
24
|
+
"typescript": "^5.8.3"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"dev": "tsc --watch",
|
|
29
|
+
"build:package": "tsc"
|
|
30
|
+
}
|
|
31
|
+
}
|