@flamecast/runtime-e2b 0.1.1

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.
@@ -0,0 +1,57 @@
1
+ import type { Runtime } from "@flamecast/protocol/runtime";
2
+ /**
3
+ * E2BRuntime — one E2B sandbox per runtime instance.
4
+ *
5
+ * When `start(instanceId)` is called, an E2B sandbox is created from the
6
+ * configured base template, the runtime-host Go binary is uploaded, and a
7
+ * single runtime-host process is started. Sessions are managed by the
8
+ * runtime-host via its multi-session HTTP and WebSocket API.
9
+ *
10
+ * `pause(instanceId)` pauses the sandbox (freezing the runtime-host).
11
+ * `stop(instanceId)` kills the sandbox entirely.
12
+ */
13
+ export declare class E2BRuntime implements Runtime {
14
+ private readonly apiKey;
15
+ private readonly template;
16
+ private readonly runtimeHostPort;
17
+ private readonly workspace;
18
+ /** instanceName → E2B sandbox + runtime-host info */
19
+ private readonly instances;
20
+ /** sessionId → which instance it belongs to */
21
+ private readonly sessions;
22
+ /**
23
+ * Optional URL to fetch the session-host binary from instead of reading it
24
+ * from the local filesystem. Required in environments without filesystem
25
+ * access (e.g. Cloudflare Workers).
26
+ */
27
+ private readonly sessionHostUrl;
28
+ constructor(opts: {
29
+ apiKey: string;
30
+ template?: string;
31
+ runtimeHostPort?: number;
32
+ /** URL to fetch the session-host binary from (for environments without filesystem access). */
33
+ sessionHostUrl?: string;
34
+ /** Working directory inside the sandbox. Defaults to `/home/user`. */
35
+ cwd?: string;
36
+ });
37
+ start(instanceId: string): Promise<void>;
38
+ /** Upload the runtime-host binary into a sandbox. */
39
+ private uploadRuntimeHostBinary;
40
+ stop(instanceId: string): Promise<void>;
41
+ pause(instanceId: string): Promise<void>;
42
+ getInstanceStatus(instanceId: string): Promise<"running" | "stopped" | "paused" | undefined>;
43
+ getWebsocketUrl(instanceId: string): string | undefined;
44
+ fetchSession(sessionId: string, request: Request): Promise<Response>;
45
+ fetchInstance(instanceId: string, request: Request): Promise<Response>;
46
+ getRuntimeMeta(_sessionId: string): Record<string, unknown> | null;
47
+ reconnect(sessionId: string, runtimeMeta: Record<string, unknown> | null): Promise<boolean>;
48
+ dispose(): Promise<void>;
49
+ private handleStart;
50
+ private proxySessionRequest;
51
+ private getInstanceForSession;
52
+ private handleInstanceSnapshot;
53
+ private handleInstanceFilePreview;
54
+ private getRunningSandbox;
55
+ private resolveInstanceSandbox;
56
+ private waitForReady;
57
+ }
package/dist/index.js ADDED
@@ -0,0 +1,582 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { posix } from "node:path";
3
+ import { resolveSessionHostBinary, resolveSessionHostUrl, } from "@flamecast/session-host-go/resolve";
4
+ import { getRequestPath } from "./request-path.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Constants
7
+ // ---------------------------------------------------------------------------
8
+ const JSON_HEADERS = { "Content-Type": "application/json" };
9
+ const SANDBOX_BIN_PATH = "/usr/local/bin/session-host";
10
+ const SANDBOX_WORKSPACE = "/home/user";
11
+ const DEFAULT_RUNTIME_HOST_PORT = 9000;
12
+ const FLAMECAST_INSTANCE_LABEL = "flamecast.instance";
13
+ let e2bModulePromise;
14
+ function getE2BModule() {
15
+ if (!e2bModulePromise) {
16
+ e2bModulePromise = import("e2b/dist/index.mjs");
17
+ }
18
+ return e2bModulePromise;
19
+ }
20
+ async function getSandboxClass() {
21
+ const module = await getE2BModule();
22
+ return module.Sandbox;
23
+ }
24
+ function jsonResponse(body, status = 200) {
25
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
26
+ }
27
+ function globToRegexSource(pattern) {
28
+ let source = "";
29
+ for (let index = 0; index < pattern.length; index += 1) {
30
+ const char = pattern[index];
31
+ if (char === "*") {
32
+ if (pattern[index + 1] === "*") {
33
+ source += ".*";
34
+ index += 1;
35
+ }
36
+ else {
37
+ source += "[^/]*";
38
+ }
39
+ continue;
40
+ }
41
+ if (char === "?") {
42
+ source += "[^/]";
43
+ continue;
44
+ }
45
+ if ("\\^$+?.()|{}[]".includes(char)) {
46
+ source += `\\${char}`;
47
+ continue;
48
+ }
49
+ source += char;
50
+ }
51
+ return source;
52
+ }
53
+ function parseGitIgnoreRule(line) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed || trimmed.startsWith("#"))
56
+ return null;
57
+ const literal = trimmed.startsWith("\\#") || trimmed.startsWith("\\!");
58
+ const negated = !literal && trimmed.startsWith("!");
59
+ const rawPattern = negated ? trimmed.slice(1) : literal ? trimmed.slice(1) : trimmed;
60
+ if (!rawPattern)
61
+ return null;
62
+ const directoryOnly = rawPattern.endsWith("/");
63
+ const anchored = rawPattern.startsWith("/");
64
+ const normalized = rawPattern.slice(anchored ? 1 : 0, directoryOnly ? -1 : undefined);
65
+ if (!normalized)
66
+ return null;
67
+ const hasSlash = normalized.includes("/");
68
+ const source = globToRegexSource(normalized);
69
+ const regex = !hasSlash
70
+ ? new RegExp(directoryOnly ? `(^|/)${source}(/|$)` : `(^|/)${source}$`, "u")
71
+ : anchored
72
+ ? new RegExp(directoryOnly ? `^${source}(/|$)` : `^${source}$`, "u")
73
+ : new RegExp(directoryOnly ? `(^|.*/)${source}(/|$)` : `(^|.*/)${source}$`, "u");
74
+ return { negated, regex };
75
+ }
76
+ function parseGitIgnoreRules(content) {
77
+ const rules = [parseGitIgnoreRule(".git/")].filter((rule) => rule !== null);
78
+ const extra = process.env.FILE_WATCHER_IGNORE;
79
+ if (extra) {
80
+ for (const pattern of extra.split(",")) {
81
+ const rule = parseGitIgnoreRule(pattern.trim());
82
+ if (rule)
83
+ rules.push(rule);
84
+ }
85
+ }
86
+ for (const line of content.split(/\r?\n/u)) {
87
+ const rule = parseGitIgnoreRule(line);
88
+ if (rule)
89
+ rules.push(rule);
90
+ }
91
+ return rules;
92
+ }
93
+ function isGitIgnored(path, rules) {
94
+ let ignored = false;
95
+ for (const rule of rules) {
96
+ if (rule.regex.test(path)) {
97
+ ignored = !rule.negated;
98
+ }
99
+ }
100
+ return ignored;
101
+ }
102
+ function resolveWorkspacePath(workspace, filePath) {
103
+ if (!filePath || filePath.includes("\0"))
104
+ return null;
105
+ const normalized = posix.normalize(filePath);
106
+ if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) {
107
+ return null;
108
+ }
109
+ return posix.join(workspace, normalized);
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // E2BRuntime
113
+ // ---------------------------------------------------------------------------
114
+ /**
115
+ * E2BRuntime — one E2B sandbox per runtime instance.
116
+ *
117
+ * When `start(instanceId)` is called, an E2B sandbox is created from the
118
+ * configured base template, the runtime-host Go binary is uploaded, and a
119
+ * single runtime-host process is started. Sessions are managed by the
120
+ * runtime-host via its multi-session HTTP and WebSocket API.
121
+ *
122
+ * `pause(instanceId)` pauses the sandbox (freezing the runtime-host).
123
+ * `stop(instanceId)` kills the sandbox entirely.
124
+ */
125
+ export class E2BRuntime {
126
+ apiKey;
127
+ template;
128
+ runtimeHostPort;
129
+ workspace;
130
+ /** instanceName → E2B sandbox + runtime-host info */
131
+ instances = new Map();
132
+ /** sessionId → which instance it belongs to */
133
+ sessions = new Map();
134
+ /**
135
+ * Optional URL to fetch the session-host binary from instead of reading it
136
+ * from the local filesystem. Required in environments without filesystem
137
+ * access (e.g. Cloudflare Workers).
138
+ */
139
+ sessionHostUrl;
140
+ constructor(opts) {
141
+ this.apiKey = opts.apiKey;
142
+ this.template = opts.template ?? "base";
143
+ this.runtimeHostPort = opts.runtimeHostPort ?? DEFAULT_RUNTIME_HOST_PORT;
144
+ this.sessionHostUrl = opts.sessionHostUrl;
145
+ this.workspace = opts.cwd ?? SANDBOX_WORKSPACE;
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Instance lifecycle
149
+ // ---------------------------------------------------------------------------
150
+ async start(instanceId) {
151
+ console.log(`[E2BRuntime] start("${instanceId}") called`);
152
+ const existing = await this.resolveInstanceSandbox(instanceId);
153
+ console.log(`[E2BRuntime] resolveInstanceSandbox result:`, existing ? `sandbox=${existing.entry.sandboxId}, state=${existing.state}` : "null");
154
+ const Sandbox = await getSandboxClass();
155
+ if (existing) {
156
+ // Resume a paused sandbox — Sandbox.connect auto-resumes
157
+ const sandbox = await Sandbox.connect(existing.entry.sandboxId, { apiKey: this.apiKey });
158
+ const hasBinary = await sandbox.commands.run(`test -x ${SANDBOX_BIN_PATH}`).then(() => true, () => false);
159
+ if (!hasBinary) {
160
+ console.log(`[E2BRuntime] Binary missing in existing sandbox ${existing.entry.sandboxId}, uploading...`);
161
+ await this.uploadRuntimeHostBinary(sandbox);
162
+ }
163
+ const host = sandbox.getHost(this.runtimeHostPort);
164
+ this.instances.set(instanceId, {
165
+ sandboxId: existing.entry.sandboxId,
166
+ runtimeHostPort: this.runtimeHostPort,
167
+ hostUrl: `https://${host}`,
168
+ websocketUrl: `wss://${host}`,
169
+ });
170
+ console.log(`[E2BRuntime] Instance "${instanceId}" reconnected (sandbox=${existing.entry.sandboxId})`);
171
+ return;
172
+ }
173
+ // Create a new sandbox
174
+ console.log(`[E2BRuntime] Creating new sandbox with template="${this.template}"...`);
175
+ let sandbox;
176
+ try {
177
+ sandbox = await Sandbox.create(this.template, {
178
+ apiKey: this.apiKey,
179
+ timeoutMs: 60 * 60 * 1000,
180
+ metadata: { [FLAMECAST_INSTANCE_LABEL]: instanceId },
181
+ });
182
+ }
183
+ catch (err) {
184
+ console.error(`[E2BRuntime] Sandbox.create failed:`, err instanceof Error ? err.message : err);
185
+ throw err;
186
+ }
187
+ console.log(`[E2BRuntime] Sandbox created: ${sandbox.sandboxId}`);
188
+ try {
189
+ await this.uploadRuntimeHostBinary(sandbox);
190
+ }
191
+ catch (err) {
192
+ console.error(`[E2BRuntime] uploadRuntimeHostBinary failed:`, err instanceof Error ? err.message : err);
193
+ await Sandbox.kill(sandbox.sandboxId, { apiKey: this.apiKey }).catch(() => { });
194
+ throw err;
195
+ }
196
+ // Start the single runtime-host process
197
+ const logFile = `/tmp/runtime-host.log`;
198
+ console.log(`[E2BRuntime] Starting runtime-host on port ${this.runtimeHostPort}...`);
199
+ await sandbox.commands.run(`SESSION_HOST_PORT=${this.runtimeHostPort} RUNTIME_SETUP_ENABLED=1 nohup ${SANDBOX_BIN_PATH} > ${logFile} 2>&1 &`, { timeoutMs: 5_000 });
200
+ // Give it a moment to start (or crash), then check
201
+ await new Promise((r) => setTimeout(r, 2_000));
202
+ const checkProc = await sandbox.commands.run(`(ps aux | grep session-host | grep -v grep || true); echo "---LOG---"; cat ${logFile} 2>/dev/null || true`, { timeoutMs: 5_000 });
203
+ console.log(`[E2BRuntime] Process + log check:\n${checkProc.stdout.trim()}`);
204
+ if (!checkProc.stdout.includes(SANDBOX_BIN_PATH)) {
205
+ const logContent = checkProc.stdout.split("---LOG---")[1]?.trim() ?? "(no output)";
206
+ throw new Error(`Runtime-host crashed on startup. Log:\n${logContent}`);
207
+ }
208
+ const host = sandbox.getHost(this.runtimeHostPort);
209
+ const hostUrl = `https://${host}`;
210
+ const websocketUrl = `wss://${host}`;
211
+ console.log(`[E2BRuntime] Host URL: ${hostUrl}`);
212
+ this.instances.set(instanceId, {
213
+ sandboxId: sandbox.sandboxId,
214
+ runtimeHostPort: this.runtimeHostPort,
215
+ hostUrl,
216
+ websocketUrl,
217
+ });
218
+ await this.waitForReady(hostUrl);
219
+ console.log(`[E2BRuntime] Instance "${instanceId}" started (sandbox=${sandbox.sandboxId})`);
220
+ }
221
+ /** Upload the runtime-host binary into a sandbox. */
222
+ async uploadRuntimeHostBinary(sandbox) {
223
+ // E2B sandboxes are always x86_64
224
+ // resolveSessionHostBinary may throw in edge runtimes (e.g. Cloudflare Workers)
225
+ // where Node.js filesystem APIs are unavailable — treat that as "no local binary".
226
+ let localBinary = null;
227
+ try {
228
+ localBinary = resolveSessionHostBinary("amd64");
229
+ }
230
+ catch {
231
+ // Expected in environments without filesystem access (e.g. CF Workers).
232
+ }
233
+ if (localBinary) {
234
+ const binaryBlob = new Blob([readFileSync(localBinary)]);
235
+ await sandbox.files.write(SANDBOX_BIN_PATH, binaryBlob);
236
+ await sandbox.commands.run(`chmod +x ${SANDBOX_BIN_PATH}`);
237
+ }
238
+ else {
239
+ const url = this.sessionHostUrl ?? resolveSessionHostUrl();
240
+ console.log(`[E2BRuntime] Downloading runtime-host from ${url}...`);
241
+ try {
242
+ await sandbox.commands.run(`curl -sfL -o ${SANDBOX_BIN_PATH} '${url}' && chmod +x ${SANDBOX_BIN_PATH}`, { timeoutMs: 30_000 });
243
+ }
244
+ catch (err) {
245
+ throw new Error(`Failed to download runtime-host binary from ${url}: ${err instanceof Error ? err.message : err}`);
246
+ }
247
+ }
248
+ }
249
+ async stop(instanceId) {
250
+ for (const [sid, session] of this.sessions) {
251
+ if (session.instanceName === instanceId) {
252
+ this.sessions.delete(sid);
253
+ }
254
+ }
255
+ const resolved = await this.resolveInstanceSandbox(instanceId);
256
+ if (!resolved)
257
+ return;
258
+ try {
259
+ const Sandbox = await getSandboxClass();
260
+ await Sandbox.kill(resolved.entry.sandboxId, { apiKey: this.apiKey });
261
+ }
262
+ catch {
263
+ // Sandbox may already be gone
264
+ }
265
+ this.instances.delete(instanceId);
266
+ console.log(`[E2BRuntime] Instance "${instanceId}" stopped`);
267
+ }
268
+ async pause(instanceId) {
269
+ const resolved = await this.resolveInstanceSandbox(instanceId);
270
+ if (!resolved)
271
+ throw new Error(`Instance "${instanceId}" not found`);
272
+ const Sandbox = await getSandboxClass();
273
+ await Sandbox.pause(resolved.entry.sandboxId, { apiKey: this.apiKey });
274
+ console.log(`[E2BRuntime] Instance "${instanceId}" paused`);
275
+ }
276
+ async getInstanceStatus(instanceId) {
277
+ const resolved = await this.resolveInstanceSandbox(instanceId);
278
+ if (!resolved)
279
+ return undefined;
280
+ if (resolved.state === "paused")
281
+ return "paused";
282
+ if (resolved.state === "running")
283
+ return "running";
284
+ return "stopped";
285
+ }
286
+ getWebsocketUrl(instanceId) {
287
+ const entry = this.instances.get(instanceId);
288
+ return entry?.websocketUrl;
289
+ }
290
+ // ---------------------------------------------------------------------------
291
+ // Session handling — route to the single runtime-host
292
+ // ---------------------------------------------------------------------------
293
+ async fetchSession(sessionId, request) {
294
+ const path = getRequestPath(request);
295
+ if (path.endsWith("/start") && request.method === "POST") {
296
+ return this.handleStart(sessionId, request);
297
+ }
298
+ // All other session requests proxy to /sessions/{sessionId}{path}
299
+ return this.proxySessionRequest(sessionId, path, request);
300
+ }
301
+ async fetchInstance(instanceId, request) {
302
+ const path = new URL(request.url).pathname;
303
+ if (path === "/fs/snapshot" && request.method === "GET") {
304
+ return this.handleInstanceSnapshot(instanceId, request);
305
+ }
306
+ if (path === "/files" && request.method === "GET") {
307
+ return this.handleInstanceFilePreview(instanceId, request);
308
+ }
309
+ return jsonResponse({ error: `Unsupported runtime request: ${request.method} ${path}` }, 404);
310
+ }
311
+ getRuntimeMeta(_sessionId) {
312
+ // Return the first instance (most common case)
313
+ for (const [instanceName, entry] of this.instances) {
314
+ return {
315
+ instanceName,
316
+ sandboxId: entry.sandboxId,
317
+ hostUrl: entry.hostUrl,
318
+ websocketUrl: entry.websocketUrl,
319
+ };
320
+ }
321
+ return null;
322
+ }
323
+ async reconnect(sessionId, runtimeMeta) {
324
+ if (!runtimeMeta)
325
+ return false;
326
+ const instanceName = typeof runtimeMeta.instanceName === "string" ? runtimeMeta.instanceName : undefined;
327
+ const sandboxId = typeof runtimeMeta.sandboxId === "string" ? runtimeMeta.sandboxId : undefined;
328
+ const hostUrl = typeof runtimeMeta.hostUrl === "string" ? runtimeMeta.hostUrl : undefined;
329
+ const websocketUrl = typeof runtimeMeta.websocketUrl === "string" ? runtimeMeta.websocketUrl : undefined;
330
+ if (!instanceName || !sandboxId || !hostUrl || !websocketUrl)
331
+ return false;
332
+ try {
333
+ if (!this.instances.has(instanceName)) {
334
+ const Sandbox = await getSandboxClass();
335
+ const info = await Sandbox.getFullInfo(sandboxId, { apiKey: this.apiKey });
336
+ if (info.state !== "running")
337
+ return false;
338
+ this.instances.set(instanceName, {
339
+ sandboxId,
340
+ runtimeHostPort: this.runtimeHostPort,
341
+ hostUrl,
342
+ websocketUrl,
343
+ });
344
+ }
345
+ // Verify the session is alive on the runtime-host
346
+ const resp = await fetch(`${hostUrl}/sessions/${sessionId}/health`).catch(() => null);
347
+ if (!resp?.ok)
348
+ return false;
349
+ this.sessions.set(sessionId, { instanceName });
350
+ return true;
351
+ }
352
+ catch {
353
+ return false;
354
+ }
355
+ }
356
+ async dispose() {
357
+ const instanceNames = [...this.instances.keys()];
358
+ await Promise.allSettled(instanceNames.map((name) => this.stop(name)));
359
+ this.instances.clear();
360
+ this.sessions.clear();
361
+ }
362
+ // ---------------------------------------------------------------------------
363
+ // Request handlers
364
+ // ---------------------------------------------------------------------------
365
+ async handleStart(sessionId, request) {
366
+ console.log(`[E2BRuntime] handleStart called for session "${sessionId}"`);
367
+ try {
368
+ const parsed = JSON.parse(await request.text());
369
+ const instanceName = typeof parsed.instanceName === "string" ? parsed.instanceName : undefined;
370
+ if (!instanceName) {
371
+ return jsonResponse({ error: "Missing instanceName — create a runtime instance first" }, 400);
372
+ }
373
+ const resolved = await this.resolveInstanceSandbox(instanceName);
374
+ if (!resolved) {
375
+ return jsonResponse({ error: `Runtime instance "${instanceName}" not found` }, 404);
376
+ }
377
+ const inst = resolved.entry;
378
+ // Forward to runtime-host at /sessions/{sessionId}/start
379
+ parsed.workspace = this.workspace;
380
+ delete parsed.instanceName;
381
+ const resp = await fetch(`${inst.hostUrl}/sessions/${sessionId}/start`, {
382
+ method: "POST",
383
+ headers: JSON_HEADERS,
384
+ body: JSON.stringify(parsed),
385
+ });
386
+ const text = await resp.text();
387
+ let result;
388
+ try {
389
+ result = JSON.parse(text);
390
+ }
391
+ catch {
392
+ throw new Error(`RuntimeHost /sessions/${sessionId}/start failed (${resp.status}): ${text}`);
393
+ }
394
+ if (!resp.ok) {
395
+ throw new Error(`RuntimeHost /sessions/${sessionId}/start failed (${resp.status}): ${result.error ?? text}`);
396
+ }
397
+ // Track session → instance mapping
398
+ this.sessions.set(sessionId, { instanceName });
399
+ // Inject shared instance URLs
400
+ result.hostUrl = inst.hostUrl;
401
+ result.websocketUrl = inst.websocketUrl;
402
+ return new Response(JSON.stringify(result), {
403
+ status: resp.status,
404
+ headers: JSON_HEADERS,
405
+ });
406
+ }
407
+ catch (err) {
408
+ return jsonResponse({ error: err instanceof Error ? err.message : "Failed to start session" }, 500);
409
+ }
410
+ }
411
+ async proxySessionRequest(sessionId, path, request) {
412
+ const entry = this.getInstanceForSession(sessionId);
413
+ if (!entry) {
414
+ return jsonResponse({ error: `Session "${sessionId}" not found` }, 404);
415
+ }
416
+ const body = request.method !== "GET" ? await request.text() : undefined;
417
+ const resp = await fetch(`${entry.hostUrl}/sessions/${sessionId}${path}`, {
418
+ method: request.method,
419
+ headers: JSON_HEADERS,
420
+ body,
421
+ });
422
+ return new Response(await resp.text(), {
423
+ status: resp.status,
424
+ headers: JSON_HEADERS,
425
+ });
426
+ }
427
+ getInstanceForSession(sessionId) {
428
+ const session = this.sessions.get(sessionId);
429
+ if (!session)
430
+ return null;
431
+ return this.instances.get(session.instanceName) ?? null;
432
+ }
433
+ async handleInstanceSnapshot(instanceId, request) {
434
+ const sandbox = await this.getRunningSandbox(instanceId);
435
+ if (sandbox instanceof Response)
436
+ return sandbox;
437
+ const url = new URL(request.url);
438
+ const showAllFiles = url.searchParams.get("showAllFiles") === "true";
439
+ const requestedPath = url.searchParams.get("path");
440
+ const targetDir = requestedPath ? posix.resolve(requestedPath) : this.workspace;
441
+ let entries;
442
+ try {
443
+ const listedEntries = await sandbox.files.list(targetDir, { depth: 1 });
444
+ const mappedEntries = [];
445
+ for (const entry of listedEntries) {
446
+ const name = posix.basename(entry.path);
447
+ if (!name)
448
+ continue;
449
+ mappedEntries.push({
450
+ path: name,
451
+ type: entry.type === "dir" ? "directory" : entry.type === "file" ? "file" : "other",
452
+ });
453
+ }
454
+ entries = mappedEntries;
455
+ }
456
+ catch (error) {
457
+ return jsonResponse({
458
+ error: error instanceof Error ? error.message : "Failed to read runtime filesystem",
459
+ }, 500);
460
+ }
461
+ if (!showAllFiles) {
462
+ // Apply .gitignore rules from the directory being listed
463
+ const gitIgnoreContents = await sandbox.files
464
+ .read(posix.join(targetDir, ".gitignore"), { format: "text" })
465
+ .catch(() => "");
466
+ const rules = parseGitIgnoreRules(gitIgnoreContents);
467
+ if (rules.length > 0) {
468
+ entries = entries.filter((entry) => !isGitIgnored(entry.path, rules));
469
+ }
470
+ }
471
+ const maxEntries = 10_000;
472
+ const truncated = entries.length > maxEntries;
473
+ return jsonResponse({
474
+ root: this.workspace,
475
+ path: targetDir,
476
+ entries: truncated ? entries.slice(0, maxEntries) : entries,
477
+ truncated,
478
+ maxEntries,
479
+ });
480
+ }
481
+ async handleInstanceFilePreview(instanceId, request) {
482
+ const sandbox = await this.getRunningSandbox(instanceId);
483
+ if (sandbox instanceof Response)
484
+ return sandbox;
485
+ const filePath = new URL(request.url).searchParams.get("path");
486
+ if (!filePath) {
487
+ return jsonResponse({ error: "Missing ?path= parameter" }, 400);
488
+ }
489
+ const resolvedPath = resolveWorkspacePath(this.workspace, filePath);
490
+ if (!resolvedPath) {
491
+ return jsonResponse({ error: "Path outside workspace" }, 403);
492
+ }
493
+ try {
494
+ const info = await sandbox.files.getInfo(resolvedPath);
495
+ if (info.type !== "file") {
496
+ return jsonResponse({ error: `Cannot read: ${filePath}` }, 404);
497
+ }
498
+ const maxChars = 100_000;
499
+ const content = await sandbox.files.read(resolvedPath, { format: "text" });
500
+ return jsonResponse({
501
+ path: filePath,
502
+ content: content.slice(0, maxChars),
503
+ truncated: content.length > maxChars || info.size > maxChars,
504
+ maxChars,
505
+ });
506
+ }
507
+ catch {
508
+ return jsonResponse({ error: `Cannot read: ${filePath}` }, 404);
509
+ }
510
+ }
511
+ async getRunningSandbox(instanceId) {
512
+ const resolved = await this.resolveInstanceSandbox(instanceId);
513
+ if (!resolved) {
514
+ return jsonResponse({ error: `Runtime instance "${instanceId}" not found` }, 404);
515
+ }
516
+ if (resolved.state !== "running") {
517
+ return jsonResponse({ error: `Runtime instance "${instanceId}" is not running` }, 409);
518
+ }
519
+ const Sandbox = await getSandboxClass();
520
+ return Sandbox.connect(resolved.entry.sandboxId, { apiKey: this.apiKey });
521
+ }
522
+ async resolveInstanceSandbox(instanceId) {
523
+ const Sandbox = await getSandboxClass();
524
+ const tracked = this.instances.get(instanceId);
525
+ if (tracked) {
526
+ const info = await Sandbox.getFullInfo(tracked.sandboxId, { apiKey: this.apiKey }).catch(() => null);
527
+ if (info) {
528
+ return { entry: tracked, state: info.state };
529
+ }
530
+ this.instances.delete(instanceId);
531
+ }
532
+ const paginator = Sandbox.list({
533
+ apiKey: this.apiKey,
534
+ limit: 1,
535
+ query: {
536
+ metadata: { [FLAMECAST_INSTANCE_LABEL]: instanceId },
537
+ },
538
+ });
539
+ if (!paginator.hasNext)
540
+ return null;
541
+ const sandboxes = await paginator.nextItems().catch(() => []);
542
+ const match = sandboxes.find((sandbox) => sandbox.metadata[FLAMECAST_INSTANCE_LABEL] === instanceId);
543
+ if (!match)
544
+ return null;
545
+ // We need to connect to get the host URL
546
+ const sandbox = await Sandbox.connect(match.sandboxId, { apiKey: this.apiKey });
547
+ const host = sandbox.getHost(this.runtimeHostPort);
548
+ const entry = {
549
+ sandboxId: match.sandboxId,
550
+ runtimeHostPort: this.runtimeHostPort,
551
+ hostUrl: `https://${host}`,
552
+ websocketUrl: `wss://${host}`,
553
+ };
554
+ this.instances.set(instanceId, entry);
555
+ return { entry, state: match.state };
556
+ }
557
+ // ---------------------------------------------------------------------------
558
+ // Readiness check
559
+ // ---------------------------------------------------------------------------
560
+ async waitForReady(hostUrl, timeoutMs = 30_000) {
561
+ const deadline = Date.now() + timeoutMs;
562
+ let attempts = 0;
563
+ while (Date.now() < deadline) {
564
+ attempts++;
565
+ try {
566
+ const resp = await fetch(`${hostUrl}/health`);
567
+ if (resp.ok) {
568
+ console.log(`[E2BRuntime] Runtime-host ready after ${attempts} attempts`);
569
+ return;
570
+ }
571
+ console.log(`[E2BRuntime] Health check attempt ${attempts}: status ${resp.status}`);
572
+ }
573
+ catch (err) {
574
+ if (attempts % 5 === 0) {
575
+ console.log(`[E2BRuntime] Health check attempt ${attempts}: ${err instanceof Error ? err.message : "connection failed"}`);
576
+ }
577
+ }
578
+ await new Promise((r) => setTimeout(r, 500));
579
+ }
580
+ throw new Error(`RuntimeHost at ${hostUrl} not ready after ${timeoutMs}ms (${attempts} attempts)`);
581
+ }
582
+ }
@@ -0,0 +1 @@
1
+ export declare function getRequestPath(request: Request): string;
@@ -0,0 +1,4 @@
1
+ export function getRequestPath(request) {
2
+ const url = new URL(request.url);
3
+ return `${url.pathname}${url.search}`;
4
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@flamecast/runtime-e2b",
3
+ "version": "0.1.1",
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
+ "e2b": "^2.18.0",
19
+ "@flamecast/protocol": "0.1.1",
20
+ "@flamecast/session-host-go": "0.1.1"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "dev": "tsc --watch",
25
+ "build:package": "tsc"
26
+ }
27
+ }