diff --git a/bun.lock b/bun.lock index ef1c6c1..b017e33 100644 --- a/bun.lock +++ b/bun.lock @@ -277,9 +277,12 @@ "version": "0.4.1", "dependencies": { "@inbrowser/workspace": "^0.4.1", + "ws": "^8.21.0", }, "devDependencies": { "@types/bun": "latest", + "@types/node": "^20.0.0", + "@types/ws": "^8.18.1", "typescript": "^5.7.0", }, }, @@ -903,6 +906,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], diff --git a/examples/remote-container-bridge/README.md b/examples/remote-container-bridge/README.md index 9abcdf6..b213d9f 100644 --- a/examples/remote-container-bridge/README.md +++ b/examples/remote-container-bridge/README.md @@ -4,8 +4,10 @@ This example is the smallest real remote-container demo: open the UI, click **Run container**, and watch stdout stream back from a container while the process is still running. -It hosts the `@inbrowser/sandbox/remote` protocol over a Bun WebSocket server -and renders the demo through the shared React/Vite example shell. +It hosts the `@inbrowser/sandbox/remote` protocol through +`startRemoteContainerBridge`, auto-selects the Bun host while the demo runs under +Bun, runs Apple `container` when available, and renders through the shared +React/Vite example shell. ```bash bun run --cwd examples/remote-container-bridge start @@ -16,7 +18,7 @@ session, runs the sample command through the configured provider, appends streamed `run.output` artifacts to the terminal, and closes the session when the command exits. Diagnostics are available in the secondary Details tab. -The Bun bridge host listens on ; Vite proxies +The bridge host listens on ; Vite proxies `/bridge-config`, `/status`, `/bridge`, and authenticated forwarded port URLs to it. diff --git a/examples/remote-container-bridge/src/dev.ts b/examples/remote-container-bridge/src/dev.ts index ef35929..4bab28b 100644 --- a/examples/remote-container-bridge/src/dev.ts +++ b/examples/remote-container-bridge/src/dev.ts @@ -1,19 +1,17 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { startRemoteContainerBridge } from '@inbrowser/sandbox/remote/host'; import { readRemoteContainerBridgeEnv } from './env.js'; -import { startBridgeHostServer } from './server.js'; const here = dirname(fileURLToPath(import.meta.url)); const exampleRoot = resolve(here, '..'); const viteEntry = fileURLToPath(import.meta.resolve('vite')); const viteBin = resolve(dirname(viteEntry), '..', '..', 'bin', 'vite.js'); -const { provider, bridgePort, uiPort, allowedOrigins } = readRemoteContainerBridgeEnv(); +const { bridgeOptions, uiPort } = readRemoteContainerBridgeEnv(); const uiUrl = `http://127.0.0.1:${uiPort}/`; -const bridge = await startBridgeHostServer({ - provider, - port: bridgePort, - allowedOrigins, +const bridge = await startRemoteContainerBridge({ + ...bridgeOptions, uiUrl, }); @@ -23,7 +21,7 @@ const vite = Bun.spawn( cwd: exampleRoot, env: { ...process.env, - REMOTE_CONTAINER_BRIDGE_TARGET: `http://127.0.0.1:${bridgePort}`, + REMOTE_CONTAINER_BRIDGE_TARGET: bridge.origin, REMOTE_CONTAINER_UI_PORT: String(uiPort), }, stdout: 'inherit', @@ -31,21 +29,19 @@ const vite = Bun.spawn( }, ); -console.log(`remote container bridge API listening on http://127.0.0.1:${bridge.port}`); +console.log(`remote container bridge API listening on ${bridge.origin}`); console.log(`remote container bridge UI listening on ${uiUrl}`); -console.log(`provider=${provider.kind}`); +console.log(`provider=${bridge.provider} host=${bridge.host}`); vite.exited.then(async (code) => { - await bridge.closeSessions(); - bridge.stop(true); + await bridge.stop(); if (code !== 0) process.exit(code); }); for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.on(signal, async () => { vite.kill(signal); - await bridge.closeSessions(); - bridge.stop(true); + await bridge.stop(); process.exit(0); }); } diff --git a/examples/remote-container-bridge/src/env.ts b/examples/remote-container-bridge/src/env.ts index 1bae8b4..1ea2b12 100644 --- a/examples/remote-container-bridge/src/env.ts +++ b/examples/remote-container-bridge/src/env.ts @@ -1,13 +1,11 @@ -import { createAppleContainerProvider } from './providers/apple-container.js'; +import type { StartRemoteContainerBridgeOptions } from '@inbrowser/sandbox/remote/host'; import { createFakeContainerProvider } from './providers/fake.js'; -import type { ContainerSandboxProvider } from './providers/types.js'; export interface RemoteContainerBridgeEnv { - provider: ContainerSandboxProvider; + bridgeOptions: StartRemoteContainerBridgeOptions; providerName: string; bridgePort: number; uiPort: number; - allowedOrigins: string[]; image: string; } @@ -22,10 +20,13 @@ export function readRemoteContainerBridgeEnv(): RemoteContainerBridgeEnv { const uiOrigins = [`http://127.0.0.1:${uiPort}`, `http://localhost:${uiPort}`]; const allowedOrigins = explicitOrigins?.length ? explicitOrigins : uiOrigins; - const provider = - providerName === 'apple' - ? createAppleContainerProvider({ image }) - : createFakeContainerProvider(); + const bridgeOptions: StartRemoteContainerBridgeOptions = { + image, + provider: providerName === 'fake' ? createFakeContainerProvider() : 'auto', + host: 'auto', + port: bridgePort, + allowedOrigins, + }; - return { provider, providerName, bridgePort, uiPort, allowedOrigins, image }; + return { bridgeOptions, providerName, bridgePort, uiPort, image }; } diff --git a/examples/remote-container-bridge/src/index.ts b/examples/remote-container-bridge/src/index.ts index d481341..7a17a94 100644 --- a/examples/remote-container-bridge/src/index.ts +++ b/examples/remote-container-bridge/src/index.ts @@ -1,19 +1,17 @@ +import { startRemoteContainerBridge } from '@inbrowser/sandbox/remote/host'; import { readRemoteContainerBridgeEnv } from './env.js'; -import { startBridgeHostServer } from './server.js'; -const { provider, bridgePort, allowedOrigins } = readRemoteContainerBridgeEnv(); +const { bridgeOptions } = readRemoteContainerBridgeEnv(); -const server = await startBridgeHostServer({ provider, port: bridgePort, allowedOrigins }); +const bridge = await startRemoteContainerBridge(bridgeOptions); -console.log( - `remote container bridge listening on ws://127.0.0.1:${server.port} (${provider.kind})`, -); -console.log(`remote container bridge API available at http://127.0.0.1:${server.port}`); +console.log(`remote container bridge listening on ${bridge.origin}${bridge.bridgeUrl}`); +console.log(`remote container bridge API available at ${bridge.origin}`); +console.log(`provider=${bridge.provider} host=${bridge.host}`); for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.on(signal, async () => { - await server.closeSessions(); - server.stop(true); + await bridge.stop(); process.exit(0); }); } diff --git a/examples/remote-container-bridge/src/providers/fake.ts b/examples/remote-container-bridge/src/providers/fake.ts index 2f2645d..418e696 100644 --- a/examples/remote-container-bridge/src/providers/fake.ts +++ b/examples/remote-container-bridge/src/providers/fake.ts @@ -1,10 +1,10 @@ import type { SandboxFileEvent, SandboxSnapshot } from '@inbrowser/sandbox'; -import { createMemoryFileSystem } from '@inbrowser/workspace/fs'; import type { ContainerSandboxProvider, ContainerSession, ContainerSessionOptions, -} from './types.js'; +} from '@inbrowser/sandbox/remote/host'; +import { createMemoryFileSystem } from '@inbrowser/workspace/fs'; export function createFakeContainerProvider(): ContainerSandboxProvider { return { diff --git a/examples/remote-container-bridge/src/providers/types.ts b/examples/remote-container-bridge/src/providers/types.ts deleted file mode 100644 index 62029d4..0000000 --- a/examples/remote-container-bridge/src/providers/types.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { - SandboxDirent, - SandboxFileEvent, - SandboxSnapshot, - SandboxStats, -} from '@inbrowser/sandbox'; -import type { - RemoteHostDiagnostic, - RemotePortExposeResponse, - RemoteRunResponse, -} from '@inbrowser/sandbox/remote'; - -export type ContainerHostDiagnostic = RemoteHostDiagnostic; - -export interface ContainerExposedPort extends RemotePortExposeResponse { - targetUrl?: string; -} - -export interface ContainerSandboxProvider { - readonly kind: string; - ensureReady(): Promise; - diagnose?(): Promise; - cleanupStaleSessions?(): Promise; - createSession(options: ContainerSessionOptions): Promise; -} - -export interface ContainerSessionOptions { - id: string; - root: string; -} - -export interface ContainerSession { - readonly id: string; - readonly root: string; - run(command: string, options?: ContainerRunOptions): Promise; - readFile(path: string): Promise; - writeFile(path: string, data: Uint8Array): Promise; - mkdir(path: string, options?: { recursive?: boolean }): Promise; - readdir(path: string, options?: { withFileTypes?: boolean }): Promise; - stat(path: string): Promise; - lstat(path: string): Promise; - unlink(path: string): Promise; - rmdir(path: string, options?: { recursive?: boolean }): Promise; - rename(from: string, to: string): Promise; - snapshot(root?: string): Promise; - restore(snapshot: SandboxSnapshot, options?: { clearRoot?: boolean }): Promise; - watch(callback: (event: SandboxFileEvent) => void): () => void; - exposePort(port: number, options?: { host?: string }): Promise; - dispose(): Promise; -} - -export interface ContainerProcessOutput { - stream: 'stdout' | 'stderr'; - chunk: string; -} - -export interface ContainerRunOptions { - cwd?: string; - signal?: AbortSignal; - onOutput?: (output: ContainerProcessOutput) => void; -} diff --git a/examples/remote-container-bridge/test/demo.test.ts b/examples/remote-container-bridge/test/demo.test.ts index d486015..92dd995 100644 --- a/examples/remote-container-bridge/test/demo.test.ts +++ b/examples/remote-container-bridge/test/demo.test.ts @@ -1,8 +1,12 @@ import { describe, expect, test } from 'bun:test'; -import { type BridgeEnvelope, REMOTE_PROTOCOL_TYPES } from '@inbrowser/sandbox/remote'; -import { createAppleContainerProvider } from '../src/providers/apple-container.js'; +import { + type BridgeEnvelope, + REMOTE_PROTOCOL_TYPES, + createRemoteSandbox, +} from '@inbrowser/sandbox/remote'; +import { startBridgeHostServer } from '@inbrowser/sandbox/remote/bun'; +import { startRemoteContainerBridge } from '@inbrowser/sandbox/remote/host'; import { createFakeContainerProvider } from '../src/providers/fake.js'; -import { startBridgeHostServer } from '../src/server.js'; describe('remote container bridge demo', () => { test('serves bridge config and streams run output over WebSocket', async () => { @@ -109,8 +113,7 @@ describe('remote container bridge demo', () => { }); } } finally { - await server.closeSessions(); - server.stop(true); + await server.stop(); } }); @@ -133,8 +136,7 @@ describe('remote container bridge demo', () => { ); expect(badOrigin.status).toBe(403); } finally { - await server.closeSessions(); - server.stop(true); + await server.stop(); } }); @@ -193,8 +195,7 @@ describe('remote container bridge demo', () => { expect(await proxied.text()).toBe('proxied container response'); } finally { socket.close(); - await server.closeSessions(); - server.stop(true); + await server.stop(); upstream.stop(true); } @@ -224,89 +225,34 @@ describe('remote container bridge demo', () => { appleIntegrationTest( 'streams output from a real Apple container and cleans up the session', async () => { - const server = await startBridgeHostServer({ - provider: createAppleContainerProvider({ - image: process.env.REMOTE_CONTAINER_IMAGE ?? 'ubuntu:latest', - }), + const bridge = await startRemoteContainerBridge({ + image: process.env.REMOTE_CONTAINER_IMAGE ?? 'ubuntu:latest', + provider: 'auto', + host: 'bun', port: 0, }); - const sessionId = `apple-test-${Date.now().toString(36)}`; - const socket = new WebSocket( - `ws://127.0.0.1:${server.port}/bridge?sessionId=${sessionId}&role=browser&token=${server.bridgeToken}`, - ); - const pending = new Map< - string, - { - resolve(value: unknown): void; - reject(err: Error): void; - } - >(); const chunks: string[] = []; - let counter = 0; - - socket.addEventListener('message', (event) => { - const envelope = JSON.parse(String(event.data)) as BridgeEnvelope; - if ( - envelope.kind === 'event' && - (envelope.payload as { type?: string }).type === 'artifact' - ) { - const artifact = (envelope.payload as { artifact?: { kind?: string; chunk?: string } }) - .artifact; - if (artifact?.kind === 'run.output' && artifact.chunk) chunks.push(artifact.chunk); - } - if ((envelope.kind === 'response' || envelope.kind === 'error') && envelope.replyTo) { - const waiting = pending.get(envelope.replyTo); - if (!waiting) return; - pending.delete(envelope.replyTo); - if (envelope.kind === 'error') { - waiting.reject(new Error(String((envelope.payload as { message?: string }).message))); - } else { - waiting.resolve(envelope.payload); - } - } - }); try { - await new Promise((resolve, reject) => { - socket.addEventListener('open', () => resolve(), { once: true }); - socket.addEventListener('error', () => reject(new Error('websocket failed')), { - once: true, - }); + expect(bridge.provider).toBe('apple-container'); + const sandbox = await createRemoteSandbox({ + id: `apple-test-${Date.now().toString(36)}`, + transport: bridge.createWebSocketProvider(), + requestTimeoutMs: 120_000, + }); + sandbox.on((event) => { + if (event.type === 'artifact' && event.artifact.kind === 'run.output') { + chunks.push(String(event.artifact.chunk)); + } }); - const session = (await request(REMOTE_PROTOCOL_TYPES.sessionCreate, { - root: '/work', - })) as { root: string }; - const result = (await request(REMOTE_PROTOCOL_TYPES.runStart, { - command: 'printf "real-bridge-stream\\n"', - options: { cwd: session.root }, - })) as { exitCode: number; stdout: string }; + const result = await sandbox.runtime.run('printf "real-bridge-stream\\n"'); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('real-bridge-stream'); expect(chunks.join('')).toContain('real-bridge-stream'); + sandbox.destroy(); } finally { - socket.close(); - await server.closeSessions(); - server.stop(true); - } - - function request(type: string, payload: unknown): Promise { - counter += 1; - const id = `apple-test-${counter}`; - socket.send( - JSON.stringify({ - id, - sessionId, - kind: 'request', - type, - sentAt: Date.now(), - peer: 'browser', - payload, - } satisfies BridgeEnvelope), - ); - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - }); + await bridge.stop(); } }, 120_000, diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index 1bb3b1e..fca0938 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -53,6 +53,8 @@ when a host needs a common tool, checkpoint, event, or runtime adapter layer. - A `createWorkspaceSandbox` adapter for `@inbrowser/workspace`. - A remote bridge subpath for adapting WebSocket-backed host/container runtimes into the same sandbox contract. +- Host-only remote bridge subpaths for decision-less local container bridges, + Node/Bun daemons, command runners, and Apple `container` providers. ## What It Does Not Own diff --git a/packages/sandbox/docs/reference.md b/packages/sandbox/docs/reference.md index edcc750..b270dba 100644 --- a/packages/sandbox/docs/reference.md +++ b/packages/sandbox/docs/reference.md @@ -8,6 +8,10 @@ This page describes the public surface of `@inbrowser/sandbox`. | --- | --- | | `@inbrowser/sandbox` | Sandbox contracts, workspace adapter, standard tools, runtime adapter, checkpoints, and path helpers | | `@inbrowser/sandbox/remote` | Remote bridge contracts, `createRemoteSandbox`, generic bridge connection helpers, and WebSocket transport | +| `@inbrowser/sandbox/remote/host` | Host-side `startRemoteContainerBridge`, provider/host contracts, and auto-resolution types | +| `@inbrowser/sandbox/remote/node` | Node bridge host server and Node command runner for local WebSocket container providers | +| `@inbrowser/sandbox/remote/bun` | Bun bridge host server and Bun command runner for local WebSocket container providers | +| `@inbrowser/sandbox/remote/apple-container` | Apple `container` CLI provider for macOS host demos and integrations | | `@inbrowser/agent/sandbox` | Agent bridge that adapts sandbox tools to `AgentTools` | Related guides: @@ -191,6 +195,44 @@ envelopes can carry file events, sandbox events, port metadata, and artifact metadata. `createRemoteSandbox` re-emits remote artifacts and ports as normal `SandboxEvent` values. +Host-side bridge code can use the decision-less host API: + +```ts +import { startRemoteContainerBridge } from '@inbrowser/sandbox/remote/host'; + +const bridge = await startRemoteContainerBridge({ + image: 'ubuntu:latest', +}); + +const sandbox = await createRemoteSandbox({ + id: 'local-container-session', + transport: bridge.createWebSocketProvider(), +}); +``` + +The default resolver chooses Bun under Bun, Node under Node, and Apple +`container` when the CLI is available on macOS. Explicit composition remains +available when an app wants to choose every layer: + +```ts +import { createAppleContainerProvider } from '@inbrowser/sandbox/remote/apple-container'; +import { startNodeBridgeHostServer } from '@inbrowser/sandbox/remote/node'; + +const server = await startNodeBridgeHostServer({ + provider: createAppleContainerProvider({ + image: 'ubuntu:latest', + }), + port: 8790, +}); + +console.log(server.bridgeOrigin); +``` + +The provider contract exported from `@inbrowser/sandbox/remote/host` is runtime +specific and intentionally separate from `BridgeTransportProvider`. A WebSocket, +Realtime Database, or test transport can drive the same Apple, WSL, Docker, or +fake container provider without changing the sandbox client API. + ## Events `SandboxEvent` variants: diff --git a/packages/sandbox/docs/remote-container-bridge.md b/packages/sandbox/docs/remote-container-bridge.md index ab924af..93924d6 100644 --- a/packages/sandbox/docs/remote-container-bridge.md +++ b/packages/sandbox/docs/remote-container-bridge.md @@ -24,9 +24,14 @@ The clean package fit is: | `@inbrowser/relay` | Optional server boundary when the bridge needs authenticated remote access across devices. | | `@inbrowser/model` | Out of scope. Container execution is a tool/runtime concern, not a model provider concern. | -The concrete provider code should start as a sandbox subpath or example rather -than a new package. A new package is only warranted once the host-side bridge -pulls in platform-specific dependencies or release cadence that would make +The concrete provider code starts as host-only sandbox subpaths rather than a +new package. `@inbrowser/sandbox/remote/host` owns the decision-less +`startRemoteContainerBridge` API plus provider/host contracts, +`@inbrowser/sandbox/remote/node` owns the standard Node host, +`@inbrowser/sandbox/remote/bun` owns the Bun host, and +`@inbrowser/sandbox/remote/apple-container` owns the Apple `container` CLI +provider. A new package is only warranted once the host-side bridge pulls in +platform-specific dependencies or release cadence that would make `@inbrowser/sandbox` too heavy. ## Why Sandbox Is The Boundary @@ -118,8 +123,8 @@ interface ContainerSession { That provider can be adapted into the existing `Sandbox` shape by implementing `SandboxFileSystem`, `SandboxRuntime`, and optional `SandboxServices`. -The example host starts before `ensureReady()` succeeds, reports diagnostics -through `/status` and `host.status`, and lazily starts the provider on +The Node and Bun hosts start before `ensureReady()` succeeds, report diagnostics +through `/status` and `host.status`, and lazily start the provider on `session.create`. ## Apple Container Notes @@ -423,12 +428,14 @@ Recommended defaults: 3. Add a browser-safe `RemoteSandboxClient` that adapts a `BridgeTransportProvider` into `SandboxFileSystem`, `SandboxRuntime`, and `SandboxServices`. Done as `createRemoteSandbox`. -4. Add a Node host daemon example that implements the protocol for one container - provider. Done in `examples/remote-container-bridge`. +4. Add Node and Bun host daemons that implement the protocol for container + providers. Done as `@inbrowser/sandbox/remote/node` and + `@inbrowser/sandbox/remote/bun`, with shared host core. 5. Implement Apple `container` first through the CLI on macOS 26 because its commands cover run, exec, copy, inspect, logs, stats, ports, and image - lifecycle. Implemented in the example host with diagnostics, cleanup, and an - opt-in real integration test. + lifecycle. Implemented as `@inbrowser/sandbox/remote/apple-container` with + diagnostics, cleanup, a runtime-neutral command runner, and an opt-in real + integration test. 6. Add WSL support through the WSL container API when a Windows test host is available; keep `wslc.exe` as the parity fallback. 7. Add `RealtimeDatabaseProvider` once the event protocol and idempotency rules @@ -443,8 +450,6 @@ Recommended defaults: ## Open Questions -- Should the host daemon live in an example first, or should `@inbrowser/sandbox` - grow explicit `./remote` and `./remote/node` subpaths? - What is the smallest acceptable file sync primitive for interactive editing: `copy` per operation, tar deltas, rsync-like manifests, or git patches? - Does Apple `container` expose enough stable Swift API surface for a structured diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index ee26dc1..bfa9ec0 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -14,6 +14,22 @@ "./remote": { "types": "./dist/remote/index.d.ts", "import": "./dist/remote/index.js" + }, + "./remote/apple-container": { + "types": "./dist/remote/apple-container/index.d.ts", + "import": "./dist/remote/apple-container/index.js" + }, + "./remote/bun": { + "types": "./dist/remote/bun/index.d.ts", + "import": "./dist/remote/bun/index.js" + }, + "./remote/node": { + "types": "./dist/remote/node/index.d.ts", + "import": "./dist/remote/node/index.js" + }, + "./remote/host": { + "types": "./dist/remote/host/index.d.ts", + "import": "./dist/remote/host/index.js" } }, "files": ["dist", "README.md", "AGENTS.md", "docs"], @@ -24,10 +40,13 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@inbrowser/workspace": "^0.4.1" + "@inbrowser/workspace": "^0.4.1", + "ws": "^8.21.0" }, "devDependencies": { "@types/bun": "latest", + "@types/node": "^20.0.0", + "@types/ws": "^8.18.1", "typescript": "^5.7.0" }, "repository": { diff --git a/examples/remote-container-bridge/src/providers/apple-container.ts b/packages/sandbox/src/remote/apple-container/index.ts similarity index 67% rename from examples/remote-container-bridge/src/providers/apple-container.ts rename to packages/sandbox/src/remote/apple-container/index.ts index 6a43bb1..cfe3d20 100644 --- a/examples/remote-container-bridge/src/providers/apple-container.ts +++ b/packages/sandbox/src/remote/apple-container/index.ts @@ -1,32 +1,43 @@ -import type { SandboxDirent, SandboxSnapshot, SandboxStats } from '@inbrowser/sandbox'; +import type { SandboxDirent, SandboxSnapshot, SandboxStats } from '../../types.js'; import type { - ContainerProcessOutput, + ContainerProviderFactory, ContainerRunOptions, ContainerSandboxProvider, ContainerSession, ContainerSessionOptions, -} from './types.js'; + HostCommandRunner, +} from '../host/types.js'; +import { createNodeCommandRunner } from '../node/command.js'; export interface AppleContainerProviderOptions { image: string; containerBin?: string; + namePrefix?: string; + maxBufferedOutputChars?: number; + commandRunner?: HostCommandRunner; } -const INBROWSER_CONTAINER_PREFIX = 'inbrowser-'; -const MAX_BUFFERED_OUTPUT_CHARS = 1_048_576; +const DEFAULT_CONTAINER_PREFIX = 'inbrowser-'; +const DEFAULT_MAX_BUFFERED_OUTPUT_CHARS = 1_048_576; export function createAppleContainerProvider( options: AppleContainerProviderOptions, ): ContainerSandboxProvider { const containerBin = options.containerBin ?? 'container'; - const createdNames = new Set(); + const namePrefix = options.namePrefix ?? DEFAULT_CONTAINER_PREFIX; + const commandRunner = options.commandRunner ?? createNodeCommandRunner(); + const maxBufferedOutputChars = Math.max( + 0, + options.maxBufferedOutputChars ?? DEFAULT_MAX_BUFFERED_OUTPUT_CHARS, + ); + const activeNames = new Set(); return { kind: 'apple-container', async ensureReady() { - await runHostCommand([containerBin, 'system', 'start'], { rejectOnFailure: true }); + await commandRunner.run([containerBin, 'system', 'start'], { rejectOnFailure: true }); }, async diagnose() { - const version = await runHostCommand([containerBin, '--version']); + const version = await commandRunner.run([containerBin, '--version']); const runtimeAvailable = version.exitCode === 0; return { providerKind: 'apple-container', @@ -41,24 +52,29 @@ export function createAppleContainerProvider( }; }, async cleanupStaleSessions() { - const names = await listContainerNames(containerBin); + const names = await listContainerNames(commandRunner, containerBin, namePrefix); await Promise.all( names - .filter((name) => name.startsWith(INBROWSER_CONTAINER_PREFIX)) - .map((name) => deleteContainer(containerBin, name)), + .filter((name) => name.startsWith(namePrefix) && !activeNames.has(name)) + .map((name) => deleteContainer(commandRunner, containerBin, name)), ); }, async createSession(sessionOptions) { - const name = safeContainerName(sessionOptions.id); - await deleteContainer(containerBin, name); - await runHostCommand( + const name = safeContainerName(sessionOptions.id, namePrefix); + await deleteContainer(commandRunner, containerBin, name); + await commandRunner.run( [containerBin, 'run', '--detach', '--name', name, options.image, 'sleep', 'infinity'], { rejectOnFailure: true }, ); - createdNames.add(name); - const session = new AppleContainerSession(containerBin, name, sessionOptions, () => { - createdNames.delete(name); - }); + const session = new AppleContainerSession( + commandRunner, + containerBin, + name, + sessionOptions, + maxBufferedOutputChars, + () => activeNames.delete(name), + ); + activeNames.add(name); const mkdir = await session.run(`mkdir -p ${shellQuote(sessionOptions.root)}`, { cwd: '/' }); if (mkdir.exitCode !== 0) { await session.dispose(); @@ -69,15 +85,46 @@ export function createAppleContainerProvider( }; } +export const appleContainerProviderFactory: ContainerProviderFactory = { + kind: 'apple-container', + priority: 100, + async detect(context) { + if (!context.commandRunner && typeof process !== 'undefined' && process.platform !== 'darwin') { + return { available: false, reason: 'not running on macOS' }; + } + const containerBin = context.containerBin ?? 'container'; + const commandRunner = context.commandRunner ?? createNodeCommandRunner(); + try { + const version = await commandRunner.run([containerBin, '--version']); + return version.exitCode === 0 + ? { available: true, details: { version: version.stdout.trim() || version.stderr.trim() } } + : { + available: false, + reason: `${containerBin} command failed: ${version.stderr || version.stdout}`, + }; + } catch (err) { + return { + available: false, + reason: err instanceof Error ? err.message : String(err), + }; + } + }, + create(options) { + return createAppleContainerProvider(options); + }, +}; + class AppleContainerSession implements ContainerSession { readonly id: string; readonly root: string; constructor( + private readonly commandRunner: HostCommandRunner, private readonly containerBin: string, private readonly name: string, options: ContainerSessionOptions, - private readonly onDispose?: () => void, + private readonly maxBufferedOutputChars: number, + private readonly onDispose: () => void, ) { this.id = options.id; this.root = options.root; @@ -86,9 +133,13 @@ class AppleContainerSession implements ContainerSession { async run(command: string, options: ContainerRunOptions = {}) { const started = Date.now(); const cwd = options.cwd ?? this.root; - const result = await runHostCommand( + const result = await this.commandRunner.run( [this.containerBin, 'exec', this.name, 'sh', '-lc', `cd ${shellQuote(cwd)} && ${command}`], - { signal: options.signal, onOutput: options.onOutput }, + { + signal: options.signal, + onOutput: options.onOutput, + maxBufferedOutputChars: this.maxBufferedOutputChars, + }, ); return { stdout: result.stdout, @@ -102,7 +153,7 @@ class AppleContainerSession implements ContainerSession { } async readFile(path: string): Promise { - const result = await runHostCommand( + const result = await this.commandRunner.run( [this.containerBin, 'exec', this.name, 'sh', '-lc', `base64 < ${shellQuote(path)}`], { rejectOnFailure: true }, ); @@ -112,7 +163,7 @@ class AppleContainerSession implements ContainerSession { async writeFile(path: string, data: Uint8Array): Promise { const encoded = bytesToBase64(data); await this.run(`mkdir -p ${shellQuote(dirname(path))}`, { cwd: this.root }); - await runHostCommand( + await this.commandRunner.run( [ this.containerBin, 'exec', @@ -206,8 +257,11 @@ class AppleContainerSession implements ContainerSession { } async dispose(): Promise { - await deleteContainer(this.containerBin, this.name); - this.onDispose?.(); + try { + await deleteContainer(this.commandRunner, this.containerBin, this.name); + } finally { + this.onDispose(); + } } private async statLike(path: string): Promise { @@ -227,11 +281,11 @@ class AppleContainerSession implements ContainerSession { } private async resolveContainerAddress(): Promise { - const inspect = await runHostCommand([this.containerBin, 'inspect', this.name]); + const inspect = await this.commandRunner.run([this.containerBin, 'inspect', this.name]); const inspectIp = firstRoutableIpv4(inspect.stdout); if (inspectIp) return inspectIp; - const list = await runHostCommand([this.containerBin, 'list', '--all']); + const list = await this.commandRunner.run([this.containerBin, 'list', '--all']); const row = list.stdout .split('\n') .find((line) => line.includes(this.name) && firstRoutableIpv4(line)); @@ -244,67 +298,8 @@ class AppleContainerSession implements ContainerSession { } } -interface RunHostCommandOptions { - signal?: AbortSignal; - onOutput?: (output: ContainerProcessOutput) => void; - rejectOnFailure?: boolean; -} - -async function runHostCommand(args: string[], options: RunHostCommandOptions = {}) { - const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe', signal: options.signal }); - const [stdout, stderr, exitCode] = await Promise.all([ - readStream(proc.stdout, 'stdout', options.onOutput), - readStream(proc.stderr, 'stderr', options.onOutput), - proc.exited, - ]); - if (options.rejectOnFailure && exitCode !== 0) { - throw new Error(`${args.join(' ')} failed (${exitCode}): ${stderr.text || stdout.text}`); - } - return { - stdout: stdout.text, - stderr: stderr.text, - exitCode, - stdoutTruncated: stdout.truncated, - stderrTruncated: stderr.truncated, - }; -} - -async function readStream( - stream: ReadableStream, - name: 'stdout' | 'stderr', - onOutput?: (output: ContainerProcessOutput) => void, -): Promise<{ text: string; truncated: boolean }> { - const decoder = new TextDecoder(); - const reader = stream.getReader(); - let text = ''; - let truncated = false; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const chunkText = decoder.decode(value, { stream: true }); - if (text.length < MAX_BUFFERED_OUTPUT_CHARS) { - text += chunkText.slice(0, MAX_BUFFERED_OUTPUT_CHARS - text.length); - truncated = truncated || text.length >= MAX_BUFFERED_OUTPUT_CHARS; - } else { - truncated = true; - } - if (chunkText) onOutput?.({ stream: name, chunk: chunkText }); - } - const tail = decoder.decode(); - if (tail) { - if (text.length < MAX_BUFFERED_OUTPUT_CHARS) { - text += tail.slice(0, MAX_BUFFERED_OUTPUT_CHARS - text.length); - truncated = truncated || text.length >= MAX_BUFFERED_OUTPUT_CHARS; - } else { - truncated = true; - } - onOutput?.({ stream: name, chunk: tail }); - } - return { text, truncated }; -} - -function safeContainerName(id: string): string { - return `${INBROWSER_CONTAINER_PREFIX}${id.replace(/[^A-Za-z0-9_.-]/g, '-')}`.slice(0, 63); +function safeContainerName(id: string, prefix: string): string { + return `${prefix}${id.replace(/[^A-Za-z0-9_.-]/g, '-')}`.slice(0, 63); } function shellQuote(value: string): string { @@ -329,18 +324,26 @@ function base64ToBytes(value: string): Uint8Array { return bytes; } -async function deleteContainer(containerBin: string, name: string): Promise { - await runHostCommand([containerBin, 'stop', name]); - await runHostCommand([containerBin, 'delete', name]); +async function deleteContainer( + commandRunner: HostCommandRunner, + containerBin: string, + name: string, +): Promise { + await commandRunner.run([containerBin, 'stop', name]); + await commandRunner.run([containerBin, 'delete', name]); } -async function listContainerNames(containerBin: string): Promise { - const result = await runHostCommand([containerBin, 'list', '--all']); +async function listContainerNames( + commandRunner: HostCommandRunner, + containerBin: string, + prefix: string, +): Promise { + const result = await commandRunner.run([containerBin, 'list', '--all']); if (result.exitCode !== 0) return []; return result.stdout .split('\n') .flatMap((line) => line.trim().split(/\s+/)) - .filter((part) => part.startsWith(INBROWSER_CONTAINER_PREFIX)); + .filter((part) => part.startsWith(prefix)); } function firstRoutableIpv4(text: string): string | undefined { diff --git a/packages/sandbox/src/remote/bun/index.ts b/packages/sandbox/src/remote/bun/index.ts new file mode 100644 index 0000000..f3e13a6 --- /dev/null +++ b/packages/sandbox/src/remote/bun/index.ts @@ -0,0 +1,172 @@ +import type { Server } from 'bun'; +import { + BRIDGE_PATH, + DEFAULT_BRIDGE_HOSTNAME, + DEFAULT_BRIDGE_PORT, + createBridgeHostCore, +} from '../host/core.js'; +import type { + BridgeHostAdapter, + BridgeHostAdapterFactory, + BridgeHostServer, + BridgeHostServerOptions, + HostCommandRunOptions, + HostCommandRunner, +} from '../host/types.js'; + +interface WebSocketData { + authenticated: true; +} + +const DEFAULT_MAX_BUFFERED_OUTPUT_CHARS = 1_048_576; + +export function createBunCommandRunner(): HostCommandRunner { + return { + async run(args, options = {}) { + if (args.length === 0) throw new Error('Host command runner requires at least one arg'); + const proc = Bun.spawn(Array.from(args), { + stdout: 'pipe', + stderr: 'pipe', + signal: options.signal, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + readStream(proc.stdout, 'stdout', options), + readStream(proc.stderr, 'stderr', options), + proc.exited, + ]); + if (options.rejectOnFailure && exitCode !== 0) { + throw new Error(`${args.join(' ')} failed (${exitCode}): ${stderr.text || stdout.text}`); + } + return { + stdout: stdout.text, + stderr: stderr.text, + exitCode, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + }; + }, + }; +} + +export async function startBunBridgeHostServer( + options: BridgeHostServerOptions, +): Promise { + const hostname = options.hostname ?? DEFAULT_BRIDGE_HOSTNAME; + let currentPort = options.port ?? DEFAULT_BRIDGE_PORT; + const core = createBridgeHostCore({ + ...options, + hostKind: 'bun', + bridgeOrigin: () => `http://${hostname}:${currentPort}`, + }); + let stopped = false; + + const server: Server = Bun.serve({ + hostname, + port: options.port ?? DEFAULT_BRIDGE_PORT, + async fetch(req, server) { + const url = new URL(req.url); + if (isWebSocketUpgrade(req) && url.pathname === BRIDGE_PATH) { + const rejection = core.authenticateBridgeRequest(req); + if (rejection) return rejection; + if (server.upgrade(req, { data: { authenticated: true } satisfies WebSocketData })) return; + return new Response('WebSocket upgrade failed', { status: 426 }); + } + return core.handleHttpRequest(req); + }, + websocket: { + message(ws, message) { + void core.handleSocketMessage(ws, message); + }, + close(ws) { + void core.closeSocket(ws); + }, + }, + }); + currentPort = server.port ?? currentPort; + + return { + host: hostname, + get port() { + return currentPort; + }, + bridgeToken: core.bridgeToken, + get bridgeOrigin() { + return `http://${hostname}:${currentPort}`; + }, + bridgeUrl: core.bridgePath, + statusUrl: core.statusPath, + closeSessions: core.closeSessions, + hostStatus: () => core.hostStatus(false), + async stop() { + await core.closeSessions(); + if (!stopped) { + stopped = true; + server.stop(true); + } + }, + }; +} + +export const startBridgeHostServer = startBunBridgeHostServer; + +export const bunBridgeHostAdapterFactory: BridgeHostAdapterFactory = { + kind: 'bun', + priority: 100, + async detect() { + return typeof Bun === 'undefined' + ? { available: false, reason: 'Bun runtime is not available' } + : { available: true }; + }, + create() { + return { + kind: 'bun', + start: startBunBridgeHostServer, + } satisfies BridgeHostAdapter; + }, +}; + +async function readStream( + stream: ReadableStream, + name: 'stdout' | 'stderr', + options: HostCommandRunOptions, +): Promise<{ text: string; truncated: boolean }> { + const decoder = new TextDecoder(); + const reader = stream.getReader(); + const maxBufferedOutputChars = + options.maxBufferedOutputChars ?? DEFAULT_MAX_BUFFERED_OUTPUT_CHARS; + let text = ''; + let truncated = false; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunkText = decoder.decode(value, { stream: true }); + ({ text, truncated } = appendOutput(text, truncated, chunkText, maxBufferedOutputChars)); + if (chunkText) options.onOutput?.({ stream: name, chunk: chunkText }); + } + const tail = decoder.decode(); + if (tail) { + ({ text, truncated } = appendOutput(text, truncated, tail, maxBufferedOutputChars)); + options.onOutput?.({ stream: name, chunk: tail }); + } + return { text, truncated }; +} + +function appendOutput( + text: string, + truncated: boolean, + chunkText: string, + maxBufferedOutputChars: number, +): { text: string; truncated: boolean } { + if (text.length < maxBufferedOutputChars) { + const nextText = text + chunkText.slice(0, maxBufferedOutputChars - text.length); + return { + text: nextText, + truncated: truncated || nextText.length >= maxBufferedOutputChars, + }; + } + return { text, truncated: true }; +} + +function isWebSocketUpgrade(req: Request): boolean { + return req.headers.get('upgrade')?.toLowerCase() === 'websocket'; +} diff --git a/examples/remote-container-bridge/src/server.ts b/packages/sandbox/src/remote/host/core.ts similarity index 77% rename from examples/remote-container-bridge/src/server.ts rename to packages/sandbox/src/remote/host/core.ts index dd74a8a..8d72561 100644 --- a/examples/remote-container-bridge/src/server.ts +++ b/packages/sandbox/src/remote/host/core.ts @@ -1,37 +1,45 @@ -import type { SandboxStats } from '@inbrowser/sandbox'; +import type { SandboxStats } from '../../types.js'; import { type BridgeEnvelope, REMOTE_PROTOCOL_TYPES, type RemoteBridgeEvent, type RemoteHostDiagnostic, type RemoteHostStatusResponse, -} from '@inbrowser/sandbox/remote'; -import type { Server, ServerWebSocket } from 'bun'; +} from '../types.js'; import type { + BridgeHostServerOptions, ContainerExposedPort, ContainerSandboxProvider, ContainerSession, -} from './providers/types.js'; - -const DEFAULT_BRIDGE_PORT = 8790; - -export interface BridgeHostServerOptions { - provider: ContainerSandboxProvider; - port?: number; - token?: string; - allowedOrigins?: readonly string[]; - uiUrl?: string; + RemoteContainerBridgeClientConfig, +} from './types.js'; + +export const DEFAULT_BRIDGE_PORT = 8790; +export const DEFAULT_BRIDGE_HOSTNAME = '127.0.0.1'; +export const DEFAULT_BRIDGE_ROOT = '/work'; +export const BRIDGE_PATH = '/bridge'; +export const STATUS_PATH = '/status'; +export const BRIDGE_CONFIG_PATH = '/bridge-config'; +export const PORT_PROXY_PREFIX = '/__inbrowser/ports/'; + +export interface BridgeHostSocket { + send(message: string): unknown; } -export type BridgeHostServer = Server & { +export interface BridgeHostCore { readonly bridgeToken: string; - readonly bridgeOrigin: string; + readonly root: string; + readonly bridgePath: string; + readonly statusPath: string; + readonly bridgeConfigPath: string; + readonly portProxyPrefix: string; + clientConfig(): RemoteContainerBridgeClientConfig; + authenticateBridgeRequest(req: Request): Response | undefined; + handleHttpRequest(req: Request): Promise; + handleSocketMessage(socket: BridgeHostSocket, message: unknown): Promise; + closeSocket(socket: BridgeHostSocket): Promise; closeSessions(): Promise; - hostStatus(): Promise; -}; - -interface WebSocketData { - authenticated: true; + hostStatus(authenticated?: boolean): Promise; } interface PortRoute { @@ -40,45 +48,68 @@ interface PortRoute { targetUrl: string; } -export async function startBridgeHostServer( - options: BridgeHostServerOptions, -): Promise { +export function createBridgeHostCore( + options: BridgeHostServerOptions & { + hostKind: string; + bridgeOrigin(): string; + }, +): BridgeHostCore { const sessions = new Map(); - const socketSessions = new WeakMap, Set>(); + const socketSessions = new WeakMap>(); const seqBySession = new Map(); const portRoutes = new Map(); + const root = options.root ?? DEFAULT_BRIDGE_ROOT; const bridgeToken = options.token ?? createBridgeToken(); const readiness = createProviderReadiness(options.provider); + const allowedOrigins = options.allowedOrigins ?? uiOrigin(options.uiUrl); - void options.provider.cleanupStaleSessions?.().catch((err) => { - console.warn(`remote container stale cleanup failed: ${errorMessage(err)}`); - }); + if (options.cleanupStaleSessions !== false) { + void options.provider.cleanupStaleSessions?.().catch((err) => { + console.warn(`remote container stale cleanup failed: ${errorMessage(err)}`); + }); + } + + async function hostStatus(authenticated = false): Promise { + return { + provider: options.provider.kind, + authenticated, + status: await readiness.status(), + }; + } - const server = Bun.serve({ - port: options.port ?? DEFAULT_BRIDGE_PORT, - async fetch(req, server) { + async function closeSessions() { + const ids = Array.from(sessions.keys()); + for (const id of ids) await disposeSession(id, sessions, portRoutes); + } + + function clientConfig(): RemoteContainerBridgeClientConfig { + return { + provider: options.provider.kind, + host: options.hostKind, + token: bridgeToken, + bridgeUrl: BRIDGE_PATH, + statusUrl: STATUS_PATH, + root, + }; + } + + return { + bridgeToken, + root, + bridgePath: BRIDGE_PATH, + statusPath: STATUS_PATH, + bridgeConfigPath: BRIDGE_CONFIG_PATH, + portProxyPrefix: PORT_PROXY_PREFIX, + clientConfig, + authenticateBridgeRequest(req) { + return authenticateBridgeRequest(req, { token: bridgeToken, allowedOrigins }); + }, + async handleHttpRequest(req) { const url = new URL(req.url); - if (isWebSocketUpgrade(req)) { - const rejection = authenticateBridgeRequest(req, { - token: bridgeToken, - allowedOrigins: options.allowedOrigins, - }); - if (rejection) return rejection; - if (server.upgrade(req, { data: { authenticated: true } satisfies WebSocketData })) return; - return new Response('WebSocket upgrade failed', { status: 426 }); - } - if (url.pathname === '/status') { + if (url.pathname === STATUS_PATH) { return jsonResponse(await hostStatus(hasValidToken(req, bridgeToken))); } - if (url.pathname === '/bridge-config') { - return jsonResponse({ - provider: options.provider.kind, - token: bridgeToken, - bridgeUrl: '/bridge', - statusUrl: '/status', - root: '/work', - }); - } + if (url.pathname === BRIDGE_CONFIG_PATH) return jsonResponse(clientConfig()); if (url.pathname.startsWith(PORT_PROXY_PREFIX)) { return proxyPortRequest(req, { token: bridgeToken, @@ -87,69 +118,48 @@ export async function startBridgeHostServer( } if (options.uiUrl) return Response.redirect(options.uiUrl, 302); return new Response( - 'Remote container bridge host is running. Start the Vite UI with `bun run dev`.', + 'Remote container bridge host is running. Start a UI and connect to /bridge.', { headers: { 'content-type': 'text/plain; charset=utf-8' }, }, ); }, - websocket: { - async message(ws, message) { - const envelope = JSON.parse(String(message)) as BridgeEnvelope; - if (envelope.kind !== 'request') return; - try { - const payload = await handleRequest( - envelope, - { - provider: options.provider, - readiness, - sessions, - portRoutes, - publicOrigin: () => options.uiUrl ?? `http://127.0.0.1:${server.port}`, - bridgeToken, - hostStatus, - }, - (event) => { - sendEvent(ws, envelope.sessionId, seqBySession, event); - }, - ); - if (envelope.type === REMOTE_PROTOCOL_TYPES.sessionCreate) { - const ids = socketSessions.get(ws) ?? new Set(); - ids.add(envelope.sessionId); - socketSessions.set(ws, ids); - } - ws.send(JSON.stringify(responseEnvelope(envelope, payload))); - } catch (err) { - ws.send(JSON.stringify(errorEnvelope(envelope, err))); + async handleSocketMessage(socket, message) { + const envelope = JSON.parse(messageText(message)) as BridgeEnvelope; + if (envelope.kind !== 'request') return; + try { + const payload = await handleRequest( + envelope, + { + provider: options.provider, + readiness, + sessions, + portRoutes, + publicOrigin: () => options.uiUrl ?? options.bridgeOrigin(), + bridgeToken, + root, + hostStatus, + }, + (event) => { + sendEvent(socket, envelope.sessionId, seqBySession, event); + }, + ); + if (envelope.type === REMOTE_PROTOCOL_TYPES.sessionCreate) { + const ids = socketSessions.get(socket) ?? new Set(); + ids.add(envelope.sessionId); + socketSessions.set(socket, ids); } - }, - close(ws) { - void disposeSocketSessions(ws, socketSessions, sessions, portRoutes); - }, + socket.send(JSON.stringify(responseEnvelope(envelope, payload))); + } catch (err) { + socket.send(JSON.stringify(errorEnvelope(envelope, err))); + } }, - }); - - async function closeSessions() { - const ids = Array.from(sessions.keys()); - for (const id of ids) await disposeSession(id, sessions, portRoutes); - } - - async function hostStatus(authenticated = false): Promise { - return { - provider: options.provider.kind, - authenticated, - status: await readiness.status(), - }; - } - - return Object.assign(server, { - bridgeToken, - get bridgeOrigin() { - return `http://127.0.0.1:${server.port}`; + closeSocket(socket) { + return disposeSocketSessions(socket, socketSessions, sessions, portRoutes); }, closeSessions, - hostStatus: () => hostStatus(false), - }); + hostStatus, + }; } async function handleRequest( @@ -161,6 +171,7 @@ async function handleRequest( portRoutes: Map; publicOrigin(): string; bridgeToken: string; + root: string; hostStatus(authenticated: boolean): Promise; }, emit: (event: RemoteBridgeEvent) => void, @@ -174,7 +185,7 @@ async function handleRequest( const payload = envelope.payload as { root?: string }; const session = await context.provider.createSession({ id: envelope.sessionId, - root: payload.root ?? '/work', + root: payload.root ?? context.root, }); context.sessions.set(envelope.sessionId, session); return { @@ -332,14 +343,14 @@ async function exposePort(options: { } function sendEvent( - ws: ServerWebSocket, + socket: BridgeHostSocket, sessionId: string, seqBySession: Map, payload: RemoteBridgeEvent, ) { const seq = (seqBySession.get(sessionId) ?? 0) + 1; seqBySession.set(sessionId, seq); - ws.send( + socket.send( JSON.stringify({ id: `event-${sessionId}-${seq}`, sessionId, @@ -375,7 +386,7 @@ function errorEnvelope(request: BridgeEnvelope, err: unknown): BridgeEnvelope { replyTo: request.id, sentAt: Date.now(), peer: 'host', - payload: { message: err instanceof Error ? err.message : String(err) }, + payload: { message: errorMessage(err) }, }; } @@ -401,8 +412,6 @@ function base64ToBytes(value: string): Uint8Array { return bytes; } -const PORT_PROXY_PREFIX = '/__inbrowser/ports/'; - interface ProviderReadiness { ensureReady(): Promise; status(): Promise; @@ -478,10 +487,6 @@ function statusMessage(state: RemoteHostDiagnostic['state']): string { return 'Container provider is idle'; } -function isWebSocketUpgrade(req: Request): boolean { - return req.headers.get('upgrade')?.toLowerCase() === 'websocket'; -} - function authenticateBridgeRequest( req: Request, options: { token: string; allowedOrigins?: readonly string[] }, @@ -511,12 +516,12 @@ function hasAllowedOrigin(req: Request, allowedOrigins: readonly string[] = []): } async function disposeSocketSessions( - ws: ServerWebSocket, - socketSessions: WeakMap, Set>, + socket: BridgeHostSocket, + socketSessions: WeakMap>, sessions: Map, portRoutes: Map, ) { - const ids = socketSessions.get(ws); + const ids = socketSessions.get(socket); if (!ids) return; for (const id of ids) await disposeSession(id, sessions, portRoutes); } @@ -608,6 +613,18 @@ function createBridgeToken(): string { return crypto.randomUUID(); } +function uiOrigin(uiUrl?: string): string[] { + if (!uiUrl) return []; + return [new URL(uiUrl).origin]; +} + +function messageText(message: unknown): string { + if (typeof message === 'string') return message; + if (message instanceof Uint8Array) return new TextDecoder().decode(message); + if (message instanceof ArrayBuffer) return new TextDecoder().decode(message); + return String(message); +} + function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/packages/sandbox/src/remote/host/index.ts b/packages/sandbox/src/remote/host/index.ts new file mode 100644 index 0000000..0423cf0 --- /dev/null +++ b/packages/sandbox/src/remote/host/index.ts @@ -0,0 +1,221 @@ +import { appleContainerProviderFactory } from '../apple-container/index.js'; +import { bunBridgeHostAdapterFactory } from '../bun/index.js'; +import { nodeBridgeHostAdapterFactory } from '../node/index.js'; +import { createWebSocketBridgeProvider } from '../websocket.js'; +import { DEFAULT_BRIDGE_PORT, DEFAULT_BRIDGE_ROOT } from './core.js'; +import type { + BridgeHostAdapter, + BridgeHostAdapterFactory, + ContainerProviderFactory, + ContainerSandboxProvider, + DetectionResult, + RemoteContainerBridge, + StartRemoteContainerBridgeOptions, +} from './types.js'; + +const DEFAULT_CONTAINER_PREFIX = 'inbrowser-'; + +export async function startRemoteContainerBridge( + options: StartRemoteContainerBridgeOptions, +): Promise { + const root = options.root ?? DEFAULT_BRIDGE_ROOT; + const provider = await resolveContainerProvider(options); + const hostAdapter = await resolveHostAdapter(options); + const server = await hostAdapter.start({ + provider, + port: options.port ?? DEFAULT_BRIDGE_PORT, + hostname: options.hostname, + token: options.token, + root, + uiUrl: options.uiUrl, + allowedOrigins: options.allowedOrigins, + cleanupStaleSessions: options.cleanupStaleSessions, + }); + + return { + provider: provider.kind, + host: hostAdapter.kind, + get origin() { + return server.bridgeOrigin; + }, + bridgeUrl: server.bridgeUrl, + statusUrl: server.statusUrl, + token: server.bridgeToken, + root, + clientConfig() { + return { + provider: provider.kind, + host: hostAdapter.kind, + bridgeUrl: server.bridgeUrl, + statusUrl: server.statusUrl, + token: server.bridgeToken, + root, + }; + }, + createWebSocketProvider() { + return createWebSocketBridgeProvider({ + url: toWebSocketUrl(new URL(server.bridgeUrl, server.bridgeOrigin)), + token: server.bridgeToken, + }); + }, + status() { + return server.hostStatus(); + }, + closeSessions() { + return server.closeSessions(); + }, + stop() { + return server.stop(); + }, + }; +} + +async function resolveContainerProvider( + options: StartRemoteContainerBridgeOptions, +): Promise { + const requested = options.provider ?? 'auto'; + if (isContainerProvider(requested)) return requested; + + const factories = [...(options.providers ?? []), appleContainerProviderFactory].sort( + (a, b) => b.priority - a.priority, + ); + const context = { + image: options.image, + containerBin: options.containerBin, + commandRunner: options.commandRunner, + }; + + if (requested !== 'auto') { + const factory = factories.find((candidate) => candidate.kind === requested); + if (!factory) { + throw new Error( + `Unknown remote container provider: ${requested}. Available providers: ${factories + .map((candidate) => candidate.kind) + .join(', ')}`, + ); + } + const detection = await factory.detect(context); + if (!detection.available) { + throw new Error( + `Remote container provider ${factory.kind} is unavailable: ${detection.reason ?? 'unknown reason'}`, + ); + } + return factory.create(providerOptions(options)); + } + + const checked: Array<{ factory: ContainerProviderFactory; detection: DetectionResult }> = []; + for (const factory of factories) { + const detection = await factory.detect(context); + checked.push({ factory, detection }); + if (detection.available) return factory.create(providerOptions(options)); + } + throw new Error(formatDetectionFailure('No remote container provider available.', checked)); +} + +async function resolveHostAdapter( + options: StartRemoteContainerBridgeOptions, +): Promise { + const requested = options.host ?? 'auto'; + if (isBridgeHostAdapter(requested)) return requested; + + const factories = [ + ...(options.hosts ?? []), + bunBridgeHostAdapterFactory, + nodeBridgeHostAdapterFactory, + ].sort((a, b) => b.priority - a.priority); + + if (requested !== 'auto') { + const factory = factories.find((candidate) => candidate.kind === requested); + if (!factory) { + throw new Error( + `Unknown remote container host: ${requested}. Available hosts: ${factories + .map((candidate) => candidate.kind) + .join(', ')}`, + ); + } + const detection = await factory.detect(); + if (!detection.available) { + throw new Error( + `Remote container host ${factory.kind} is unavailable: ${detection.reason ?? 'unknown reason'}`, + ); + } + return factory.create(); + } + + const checked: Array<{ factory: BridgeHostAdapterFactory; detection: DetectionResult }> = []; + for (const factory of factories) { + const detection = await factory.detect(); + checked.push({ factory, detection }); + if (detection.available) return factory.create(); + } + throw new Error(formatDetectionFailure('No remote container host available.', checked)); +} + +function providerOptions(options: StartRemoteContainerBridgeOptions) { + return { + image: options.image, + containerBin: options.containerBin, + namePrefix: options.namePrefix ?? DEFAULT_CONTAINER_PREFIX, + maxBufferedOutputChars: options.maxBufferedOutputChars, + commandRunner: options.commandRunner, + }; +} + +function isContainerProvider(value: unknown): value is ContainerSandboxProvider { + return ( + typeof value === 'object' && + value !== null && + 'kind' in value && + 'ensureReady' in value && + 'createSession' in value + ); +} + +function isBridgeHostAdapter(value: unknown): value is BridgeHostAdapter { + return typeof value === 'object' && value !== null && 'kind' in value && 'start' in value; +} + +function formatDetectionFailure( + heading: string, + checked: readonly { + factory: { kind: string }; + detection: DetectionResult; + }[], +): string { + const rows = checked.map( + ({ factory, detection }) => + `- ${factory.kind}: ${detection.available ? 'available' : 'unavailable'}${ + detection.reason ? `, ${detection.reason}` : '' + }`, + ); + return `${heading}\n\nChecked:\n${rows.join('\n')}`; +} + +function toWebSocketUrl(url: URL): string { + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.toString(); +} + +export type { + BridgeHostAdapter, + BridgeHostAdapterFactory, + BridgeHostServer, + BridgeHostServerOptions, + ContainerExposedPort, + ContainerHostDiagnostic, + ContainerProviderFactory, + ContainerProcessOutput, + ContainerRunOptions, + ContainerSandboxProvider, + ContainerSession, + ContainerSessionOptions, + DetectionResult, + HostCommandResult, + HostCommandRunner, + HostCommandRunOptions, + ProviderDetectionContext, + RemoteContainerBridge, + RemoteContainerBridgeClientConfig, + ResolvedContainerProviderOptions, + StartRemoteContainerBridgeOptions, +} from './types.js'; diff --git a/packages/sandbox/src/remote/host/types.ts b/packages/sandbox/src/remote/host/types.ts new file mode 100644 index 0000000..606d2fd --- /dev/null +++ b/packages/sandbox/src/remote/host/types.ts @@ -0,0 +1,187 @@ +import type { + SandboxDirent, + SandboxFileEvent, + SandboxSnapshot, + SandboxStats, +} from '../../types.js'; +import type { + BridgeTransportProvider, + RemoteHostDiagnostic, + RemoteHostStatusResponse, + RemotePortExposeResponse, + RemoteRunResponse, +} from '../types.js'; + +export type ContainerHostDiagnostic = RemoteHostDiagnostic; + +export interface BridgeHostServerOptions { + provider: ContainerSandboxProvider; + port?: number; + hostname?: string; + token?: string; + root?: string; + uiUrl?: string; + allowedOrigins?: readonly string[]; + cleanupStaleSessions?: boolean; +} + +export interface BridgeHostServer { + readonly host: string; + readonly port: number; + readonly bridgeToken: string; + readonly bridgeOrigin: string; + readonly bridgeUrl: string; + readonly statusUrl: string; + closeSessions(): Promise; + hostStatus(): Promise; + stop(): Promise; +} + +export interface HostCommandRunner { + run(args: readonly string[], options?: HostCommandRunOptions): Promise; +} + +export interface HostCommandRunOptions { + signal?: AbortSignal; + onOutput?: (output: ContainerProcessOutput) => void; + rejectOnFailure?: boolean; + maxBufferedOutputChars?: number; +} + +export interface HostCommandResult { + stdout: string; + stderr: string; + exitCode: number; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; +} + +export interface DetectionResult { + available: boolean; + reason?: string; + details?: Record; +} + +export interface ProviderDetectionContext { + image: string; + containerBin?: string; + commandRunner?: HostCommandRunner; +} + +export interface ResolvedContainerProviderOptions { + image: string; + containerBin?: string; + namePrefix: string; + maxBufferedOutputChars?: number; + commandRunner?: HostCommandRunner; +} + +export interface ContainerProviderFactory { + readonly kind: string; + readonly priority: number; + detect(context: ProviderDetectionContext): Promise; + create(options: ResolvedContainerProviderOptions): ContainerSandboxProvider; +} + +export interface BridgeHostAdapter { + readonly kind: string; + start(options: BridgeHostServerOptions): Promise; +} + +export interface BridgeHostAdapterFactory { + readonly kind: 'node' | 'bun' | string; + readonly priority: number; + detect(): Promise; + create(): BridgeHostAdapter; +} + +export interface StartRemoteContainerBridgeOptions { + image: string; + provider?: 'auto' | string | ContainerSandboxProvider; + host?: 'auto' | 'node' | 'bun' | BridgeHostAdapter; + port?: number; + hostname?: string; + uiUrl?: string; + allowedOrigins?: readonly string[]; + token?: string; + root?: string; + namePrefix?: string; + containerBin?: string; + maxBufferedOutputChars?: number; + cleanupStaleSessions?: boolean; + commandRunner?: HostCommandRunner; + providers?: readonly ContainerProviderFactory[]; + hosts?: readonly BridgeHostAdapterFactory[]; +} + +export interface RemoteContainerBridgeClientConfig { + provider: string; + host: string; + bridgeUrl: string; + statusUrl: string; + token: string; + root: string; +} + +export interface RemoteContainerBridge { + readonly provider: string; + readonly host: string; + readonly origin: string; + readonly bridgeUrl: string; + readonly statusUrl: string; + readonly token: string; + readonly root: string; + clientConfig(): RemoteContainerBridgeClientConfig; + createWebSocketProvider(): BridgeTransportProvider; + status(): Promise; + closeSessions(): Promise; + stop(): Promise; +} + +export interface ContainerExposedPort extends RemotePortExposeResponse { + targetUrl?: string; +} + +export interface ContainerSandboxProvider { + readonly kind: string; + ensureReady(): Promise; + diagnose?(): Promise; + cleanupStaleSessions?(): Promise; + createSession(options: ContainerSessionOptions): Promise; +} + +export interface ContainerSessionOptions { + id: string; + root: string; +} + +export interface ContainerSession { + readonly id: string; + readonly root: string; + run(command: string, options?: ContainerRunOptions): Promise; + readFile(path: string): Promise; + writeFile(path: string, data: Uint8Array): Promise; + mkdir(path: string, options?: { recursive?: boolean }): Promise; + readdir(path: string, options?: { withFileTypes?: boolean }): Promise; + stat(path: string): Promise; + lstat(path: string): Promise; + unlink(path: string): Promise; + rmdir(path: string, options?: { recursive?: boolean }): Promise; + rename(from: string, to: string): Promise; + snapshot(root?: string): Promise; + restore(snapshot: SandboxSnapshot, options?: { clearRoot?: boolean }): Promise; + watch(callback: (event: SandboxFileEvent) => void): () => void; + exposePort(port: number, options?: { host?: string }): Promise; + dispose(): Promise; +} + +export interface ContainerProcessOutput { + stream: 'stdout' | 'stderr'; + chunk: string; +} + +export interface ContainerRunOptions { + cwd?: string; + signal?: AbortSignal; + onOutput?: (output: ContainerProcessOutput) => void; +} diff --git a/packages/sandbox/src/remote/node/command.ts b/packages/sandbox/src/remote/node/command.ts new file mode 100644 index 0000000..cb44bae --- /dev/null +++ b/packages/sandbox/src/remote/node/command.ts @@ -0,0 +1,75 @@ +import { spawn } from 'node:child_process'; +import type { Readable } from 'node:stream'; +import type { HostCommandResult, HostCommandRunOptions, HostCommandRunner } from '../host/types.js'; + +const DEFAULT_MAX_BUFFERED_OUTPUT_CHARS = 1_048_576; + +export function createNodeCommandRunner(): HostCommandRunner { + return { + async run(args, options = {}) { + if (args.length === 0) throw new Error('Host command runner requires at least one arg'); + const command = args[0]; + if (!command) throw new Error('Host command runner requires a command'); + const proc = spawn(command, Array.from(args.slice(1)), { + signal: options.signal, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + readNodeStream(proc.stdout, 'stdout', options), + readNodeStream(proc.stderr, 'stderr', options), + new Promise((resolve, reject) => { + proc.on('error', reject); + proc.on('close', (code: number | null, signal: NodeJS.Signals | null) => { + resolve(code ?? (signal ? 1 : 0)); + }); + }), + ]); + if (options.rejectOnFailure && exitCode !== 0) { + throw new Error(`${args.join(' ')} failed (${exitCode}): ${stderr.text || stdout.text}`); + } + return { + stdout: stdout.text, + stderr: stderr.text, + exitCode, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies HostCommandResult; + }, + }; +} + +function readNodeStream( + stream: Readable | null, + name: 'stdout' | 'stderr', + options: HostCommandRunOptions, +): Promise<{ text: string; truncated: boolean }> { + if (!stream) return Promise.resolve({ text: '', truncated: false }); + const maxBufferedOutputChars = + options.maxBufferedOutputChars ?? DEFAULT_MAX_BUFFERED_OUTPUT_CHARS; + let text = ''; + let truncated = false; + return new Promise((resolve, reject) => { + stream.on('data', (chunk: Buffer | string) => { + const chunkText = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + ({ text, truncated } = appendOutput(text, truncated, chunkText, maxBufferedOutputChars)); + if (chunkText) options.onOutput?.({ stream: name, chunk: chunkText }); + }); + stream.on('error', reject); + stream.on('end', () => resolve({ text, truncated })); + }); +} + +function appendOutput( + text: string, + truncated: boolean, + chunkText: string, + maxBufferedOutputChars: number, +): { text: string; truncated: boolean } { + if (text.length < maxBufferedOutputChars) { + const nextText = text + chunkText.slice(0, maxBufferedOutputChars - text.length); + return { + text: nextText, + truncated: truncated || nextText.length >= maxBufferedOutputChars, + }; + } + return { text, truncated: true }; +} diff --git a/packages/sandbox/src/remote/node/index.ts b/packages/sandbox/src/remote/node/index.ts new file mode 100644 index 0000000..5cbbbbd --- /dev/null +++ b/packages/sandbox/src/remote/node/index.ts @@ -0,0 +1,202 @@ +import { type IncomingMessage, type ServerResponse, createServer } from 'node:http'; +import type { Duplex } from 'node:stream'; +import { Readable } from 'node:stream'; +import { type WebSocket, WebSocketServer } from 'ws'; +import { + BRIDGE_PATH, + type BridgeHostSocket, + DEFAULT_BRIDGE_HOSTNAME, + DEFAULT_BRIDGE_PORT, + createBridgeHostCore, +} from '../host/core.js'; +import type { + BridgeHostAdapter, + BridgeHostAdapterFactory, + BridgeHostServer, + BridgeHostServerOptions, +} from '../host/types.js'; +export { createNodeCommandRunner } from './command.js'; + +export async function startNodeBridgeHostServer( + options: BridgeHostServerOptions, +): Promise { + const hostname = options.hostname ?? DEFAULT_BRIDGE_HOSTNAME; + const httpServer = createServer(); + const wss = new WebSocketServer({ noServer: true }); + const core = createBridgeHostCore({ + ...options, + hostKind: 'node', + bridgeOrigin: () => `http://${hostname}:${currentPort()}`, + }); + let stopped = false; + + httpServer.on('request', (req, res) => { + void handleRequest(req, res); + }); + httpServer.on('upgrade', (req, socket, head) => { + void handleUpgrade(req, socket, head); + }); + wss.on('connection', (ws) => { + const hostSocket = ws as unknown as BridgeHostSocket; + ws.on('message', (message) => { + void core.handleSocketMessage(hostSocket, message); + }); + ws.on('close', () => { + void core.closeSocket(hostSocket); + }); + }); + + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + httpServer.off('listening', onListening); + reject(err); + }; + const onListening = () => { + httpServer.off('error', onError); + resolve(); + }; + httpServer.once('error', onError); + httpServer.once('listening', onListening); + httpServer.listen({ host: hostname, port: options.port ?? DEFAULT_BRIDGE_PORT }); + }); + + return { + host: hostname, + get port() { + return currentPort(); + }, + bridgeToken: core.bridgeToken, + get bridgeOrigin() { + return `http://${hostname}:${currentPort()}`; + }, + bridgeUrl: core.bridgePath, + statusUrl: core.statusPath, + closeSessions: core.closeSessions, + hostStatus: () => core.hostStatus(false), + async stop() { + await core.closeSessions(); + if (stopped) return; + stopped = true; + for (const client of wss.clients) client.close(1001, 'bridge host stopped'); + await Promise.all([closeWebSocketServer(wss), closeHttpServer(httpServer)]); + }, + }; + + function currentPort(): number { + const address = httpServer.address(); + if (address && typeof address === 'object') return address.port; + return options.port ?? DEFAULT_BRIDGE_PORT; + } + + async function handleRequest(req: IncomingMessage, res: ServerResponse) { + try { + const response = await core.handleHttpRequest(toFetchRequest(req, hostname, currentPort())); + await writeResponse(res, response); + } catch (err) { + await writeResponse( + res, + new Response(err instanceof Error ? err.message : String(err), { status: 500 }), + ); + } + } + + async function handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer) { + const request = toFetchRequest(req, hostname, currentPort(), false); + const url = new URL(request.url); + if (url.pathname !== BRIDGE_PATH) { + await writeSocketResponse(socket, new Response('Not found', { status: 404 })); + return; + } + const rejection = core.authenticateBridgeRequest(request); + if (rejection) { + await writeSocketResponse(socket, rejection); + return; + } + wss.handleUpgrade(req, socket, head, (ws: WebSocket) => { + wss.emit('connection', ws, req); + }); + } +} + +export const nodeBridgeHostAdapterFactory: BridgeHostAdapterFactory = { + kind: 'node', + priority: 90, + async detect() { + return typeof process === 'undefined' + ? { available: false, reason: 'Node-compatible process runtime is not available' } + : { available: true }; + }, + create() { + return { + kind: 'node', + start: startNodeBridgeHostServer, + } satisfies BridgeHostAdapter; + }, +}; + +function toFetchRequest( + req: IncomingMessage, + hostname: string, + port: number, + includeBody = true, +): Request { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(key, item); + } else if (value !== undefined) { + headers.set(key, value); + } + } + const host = req.headers.host ?? `${hostname}:${port}`; + const url = new URL(req.url ?? '/', `http://${host}`); + const init: RequestInit = { + method: req.method, + headers, + }; + if (includeBody && req.method !== 'GET' && req.method !== 'HEAD') { + init.body = Readable.toWeb(req) as unknown as ReadableStream; + (init as RequestInit & { duplex: 'half' }).duplex = 'half'; + } + return new Request(url, init); +} + +async function writeResponse(res: ServerResponse, response: Response): Promise { + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + res.writeHead(response.status, response.statusText, headers); + if (!response.body) { + res.end(); + return; + } + const body = Buffer.from(await response.arrayBuffer()); + res.end(body); +} + +async function writeSocketResponse(socket: Duplex, response: Response): Promise { + const body = await response.text(); + const statusText = response.statusText || 'Rejected'; + const headers = new Headers(response.headers); + headers.set('content-length', String(Buffer.byteLength(body))); + headers.set('connection', 'close'); + const lines = [`HTTP/1.1 ${response.status} ${statusText}`]; + headers.forEach((value, key) => { + lines.push(`${key}: ${value}`); + }); + socket.write(`${lines.join('\r\n')}\r\n\r\n${body}`); + socket.destroy(); +} + +function closeWebSocketServer(wss: WebSocketServer): Promise { + return new Promise((resolve, reject) => { + wss.close((err) => (err ? reject(err) : resolve())); + }); +} + +function closeHttpServer(server: ReturnType): Promise { + return new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +} diff --git a/packages/sandbox/test/remote-host.test.ts b/packages/sandbox/test/remote-host.test.ts new file mode 100644 index 0000000..bd24ec2 --- /dev/null +++ b/packages/sandbox/test/remote-host.test.ts @@ -0,0 +1,449 @@ +import { describe, expect, test } from 'bun:test'; +import { createAppleContainerProvider } from '../src/remote/apple-container/index.js'; +import { startBunBridgeHostServer } from '../src/remote/bun/index.js'; +import { + type ContainerProviderFactory, + type ContainerRunOptions, + type ContainerSandboxProvider, + type ContainerSession, + type ContainerSessionOptions, + type HostCommandRunner, + startRemoteContainerBridge, +} from '../src/remote/host/index.js'; +import { + REMOTE_PROTOCOL_TYPES, + createRemoteSandbox, + createWebSocketBridgeProvider, +} from '../src/remote/index.js'; +import { startNodeBridgeHostServer } from '../src/remote/node/index.js'; +import type { SandboxSnapshot, SandboxStats } from '../src/types.js'; + +const hostStarters = [ + { kind: 'bun', start: startBunBridgeHostServer }, + { kind: 'node', start: startNodeBridgeHostServer }, +] as const; + +describe('remote bridge host servers', () => { + for (const host of hostStarters) { + test(`${host.kind} host authenticates and streams output through createRemoteSandbox`, async () => { + const state = createTrackingProvider(); + const server = await host.start({ + provider: state.provider, + port: 0, + token: `${host.kind}-bridge-token`, + uiUrl: 'http://127.0.0.1:5184', + }); + try { + expect(state.ensureReadyCalls).toBe(0); + + const root = await fetch(server.bridgeOrigin, { redirect: 'manual' }); + expect(root.status).toBe(302); + expect(root.headers.get('location')).toBe('http://127.0.0.1:5184'); + + const configResponse = await fetch(`${server.bridgeOrigin}/bridge-config`); + const config = (await configResponse.json()) as { + provider: string; + host: string; + token: string; + bridgeUrl: string; + }; + expect(config.provider).toBe('package-test'); + expect(config.host).toBe(host.kind); + expect(config.token).toBe(`${host.kind}-bridge-token`); + + const sandbox = await createRemoteSandbox({ + id: `${host.kind}-host-stream-test`, + transport: createWebSocketBridgeProvider({ + url: `ws://127.0.0.1:${server.port}${config.bridgeUrl}`, + token: config.token, + }), + requestTimeoutMs: 1_000, + }); + const chunks: string[] = []; + sandbox.on((event) => { + if (event.type === 'artifact' && event.artifact.kind === 'run.output') { + chunks.push(String(event.artifact.chunk)); + } + }); + + const result = await sandbox.runtime.run('printf "hello"'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('host stdout'); + expect(chunks.join('')).toContain('host stdout'); + expect(state.ensureReadyCalls).toBe(1); + expect(state.createdSessions).toEqual([`${host.kind}-host-stream-test`]); + + sandbox.destroy(); + await waitFor(() => state.disposedSessions.includes(`${host.kind}-host-stream-test`)); + } finally { + await server.stop(); + } + }); + + test(`${host.kind} host proxies exposed provider ports through authenticated URLs`, async () => { + const upstream = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + return new Response(`proxied:${url.pathname}${url.search}`); + }, + }); + const state = createTrackingProvider({ + targetUrlForPort: () => `http://127.0.0.1:${upstream.port}`, + }); + const server = await host.start({ + provider: state.provider, + port: 0, + token: `${host.kind}-port-token`, + }); + const sessionId = `${host.kind}-host-port-test`; + const connection = await createWebSocketBridgeProvider({ + url: `ws://127.0.0.1:${server.port}/bridge`, + token: server.bridgeToken, + }).connect({ sessionId, role: 'browser' }); + + try { + await connection.request({ + id: 'session-create', + sessionId, + type: REMOTE_PROTOCOL_TYPES.sessionCreate, + payload: { root: '/work' }, + }); + const exposed = await connection.request<{ url: string }>({ + id: 'port-expose', + sessionId, + type: REMOTE_PROTOCOL_TYPES.portExpose, + payload: { port: upstream.port, host: '127.0.0.1' }, + }); + + const missingToken = new URL(exposed.payload.url); + missingToken.searchParams.delete('token'); + expect((await fetch(missingToken)).status).toBe(401); + + const forwarded = new URL(exposed.payload.url); + forwarded.pathname = `${forwarded.pathname}hello`; + forwarded.searchParams.set('q', '1'); + expect(await (await fetch(forwarded)).text()).toBe('proxied:/hello?q=1'); + + await connection.close('test complete'); + await waitFor(() => state.disposedSessions.includes(sessionId)); + } finally { + await connection.close('cleanup'); + await server.stop(); + upstream.stop(true); + } + }); + } +}); + +describe('startRemoteContainerBridge', () => { + test('starts with an explicit provider instance and returns a ready WebSocket provider', async () => { + const state = createTrackingProvider(); + const bridge = await startRemoteContainerBridge({ + image: 'ubuntu:latest', + provider: state.provider, + host: 'bun', + port: 0, + token: 'high-level-token', + }); + try { + expect(bridge.provider).toBe('package-test'); + expect(bridge.host).toBe('bun'); + expect(bridge.clientConfig()).toMatchObject({ + provider: 'package-test', + host: 'bun', + token: 'high-level-token', + bridgeUrl: '/bridge', + statusUrl: '/status', + root: '/work', + }); + + const sandbox = await createRemoteSandbox({ + id: 'high-level-session', + transport: bridge.createWebSocketProvider(), + requestTimeoutMs: 1_000, + }); + const result = await sandbox.runtime.run('echo high-level'); + expect(result.exitCode).toBe(0); + sandbox.destroy(); + await waitFor(() => state.disposedSessions.includes('high-level-session')); + } finally { + await bridge.stop(); + } + }); + + test('auto host resolution chooses Bun when running under Bun', async () => { + const state = createTrackingProvider(); + const bridge = await startRemoteContainerBridge({ + image: 'ubuntu:latest', + provider: state.provider, + port: 0, + }); + try { + expect(bridge.host).toBe('bun'); + } finally { + await bridge.stop(); + } + }); + + test('auto provider resolution selects Apple when command detection succeeds', async () => { + const runner = createScriptedAppleRunner(); + const bridge = await startRemoteContainerBridge({ + image: 'ubuntu:latest', + provider: 'auto', + host: 'bun', + port: 0, + commandRunner: runner, + }); + try { + expect(bridge.provider).toBe('apple-container'); + } finally { + await bridge.stop(); + } + }); + + test('auto provider resolution reports checked providers when none are available', async () => { + await expect( + startRemoteContainerBridge({ + image: 'ubuntu:latest', + provider: 'auto', + host: 'bun', + commandRunner: { + async run() { + throw new Error('container command not found'); + }, + }, + }), + ).rejects.toThrow('No remote container provider available'); + }); + + test('explicit string providers and hosts must match registered factories', async () => { + const unavailableProvider: ContainerProviderFactory = { + kind: 'unavailable-test', + priority: 200, + async detect() { + return { available: false, reason: 'test provider disabled' }; + }, + create() { + throw new Error('should not create'); + }, + }; + + await expect( + startRemoteContainerBridge({ + image: 'ubuntu:latest', + provider: 'unavailable-test', + host: 'bun', + providers: [unavailableProvider], + }), + ).rejects.toThrow('test provider disabled'); + }); +}); + +describe('apple container provider', () => { + test('uses an injected command runner for lifecycle, streaming, and cleanup', async () => { + const runner = createScriptedAppleRunner(); + const provider = createAppleContainerProvider({ + image: 'ubuntu:latest', + commandRunner: runner, + maxBufferedOutputChars: 4, + }); + + await provider.ensureReady(); + const session = await provider.createSession({ id: 'active', root: '/work' }); + const chunks: string[] = []; + const result = await session.run('printf "streamed"', { + onOutput(output) { + chunks.push(`${output.stream}:${output.chunk}`); + }, + }); + await provider.cleanupStaleSessions(); + expect(runner.deletedNames.filter((name) => name === 'inbrowser-active')).toHaveLength(1); + await session.dispose(); + + expect(result.stdout).toBe('streamed'); + expect(chunks).toContain('stdout:streamed'); + expect(runner.calls.some((call) => call.maxBufferedOutputChars === 4)).toBe(true); + expect(runner.deletedNames).toContain('inbrowser-stale'); + expect(runner.deletedNames).toContain('inbrowser-active'); + expect(runner.deletedNames.filter((name) => name === 'inbrowser-active')).toHaveLength(2); + }); +}); + +function createTrackingProvider( + options: { + targetUrlForPort?: (port: number, host: string) => string; + } = {}, +): { + provider: ContainerSandboxProvider; + readonly ensureReadyCalls: number; + readonly createdSessions: readonly string[]; + readonly disposedSessions: readonly string[]; +} { + let ensureReadyCalls = 0; + const createdSessions: string[] = []; + const disposedSessions: string[] = []; + + return { + get ensureReadyCalls() { + return ensureReadyCalls; + }, + get createdSessions() { + return createdSessions; + }, + get disposedSessions() { + return disposedSessions; + }, + provider: { + kind: 'package-test', + async ensureReady() { + ensureReadyCalls += 1; + }, + async diagnose() { + return { + providerKind: 'package-test', + state: ensureReadyCalls > 0 ? 'ready' : 'idle', + runtimeAvailable: true, + systemReady: ensureReadyCalls > 0, + checkedAt: Date.now(), + }; + }, + async cleanupStaleSessions() {}, + async createSession(sessionOptions) { + createdSessions.push(sessionOptions.id); + return createTestSession(sessionOptions, { + targetUrlForPort: options.targetUrlForPort, + onDispose() { + disposedSessions.push(sessionOptions.id); + }, + }); + }, + }, + }; +} + +function createTestSession( + options: ContainerSessionOptions, + hooks: { + targetUrlForPort?: (port: number, host: string) => string; + onDispose(): void; + }, +): ContainerSession { + return { + id: options.id, + root: options.root, + async run(command: string, runOptions?: ContainerRunOptions) { + const cwd = runOptions?.cwd ?? options.root; + const chunks = [`host stdout: ${command}\n`, 'host stdout: done\n']; + for (const chunk of chunks) { + runOptions?.onOutput?.({ stream: 'stdout', chunk }); + await Bun.sleep(1); + } + return { + stdout: chunks.join(''), + stderr: '', + exitCode: 0, + cwd, + durationMs: 2, + }; + }, + async readFile() { + return new Uint8Array(); + }, + async writeFile() {}, + async mkdir() {}, + async readdir() { + return []; + }, + async stat() { + return testStats(); + }, + async lstat() { + return testStats(); + }, + async unlink() {}, + async rmdir() {}, + async rename() {}, + async snapshot(root = options.root): Promise { + return { root, entries: [], createdAt: Date.now() }; + }, + async restore() {}, + watch() { + return () => {}; + }, + async exposePort(port, exposeOptions) { + const host = exposeOptions?.host ?? '127.0.0.1'; + return { + id: `test-port-${port}`, + port, + host, + targetUrl: hooks.targetUrlForPort?.(port, host) ?? `http://${host}:${port}`, + }; + }, + async dispose() { + hooks.onDispose(); + }, + }; +} + +function createScriptedAppleRunner(): HostCommandRunner & { + calls: Array<{ args: readonly string[]; maxBufferedOutputChars?: number }>; + deletedNames: string[]; +} { + const calls: Array<{ args: readonly string[]; maxBufferedOutputChars?: number }> = []; + const deletedNames: string[] = []; + const runner: HostCommandRunner & { + calls: Array<{ args: readonly string[]; maxBufferedOutputChars?: number }>; + deletedNames: string[]; + } = { + calls, + deletedNames, + async run(args, options = {}) { + calls.push({ args, maxBufferedOutputChars: options.maxBufferedOutputChars }); + const [bin, command, ...rest] = args; + if (bin !== 'container') return result('', `unknown command: ${args.join(' ')}`, 1); + if (command === '--version') return result('container 1.0.0\n'); + if (command === 'system' && rest[0] === 'start') return result(''); + if (command === 'list') return result('inbrowser-active inbrowser-stale unrelated\n'); + if (command === 'stop' || command === 'delete') { + if (command === 'delete' && rest[0]) deletedNames.push(String(rest[0])); + return result(''); + } + if (command === 'run') return result(''); + if (command === 'exec') { + const shellCommand = String(rest.at(-1) ?? ''); + if (shellCommand.includes('printf "streamed"')) { + options.onOutput?.({ stream: 'stdout', chunk: 'streamed' }); + return result('streamed'); + } + if (shellCommand.includes('mkdir -p')) return result(''); + return result(''); + } + return result(''); + }, + }; + return runner; +} + +function result(stdout: string, stderr = '', exitCode = 0) { + return { stdout, stderr, exitCode, stdoutTruncated: false, stderrTruncated: false }; +} + +function testStats(): SandboxStats { + return { + type: 'file', + size: 0, + mtimeMs: 0, + ctimeMs: 0, + isFile: () => true, + isDirectory: () => false, + }; +} + +async function waitFor(predicate: () => boolean): Promise { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > 1_000) throw new Error('Timed out waiting for condition'); + await Bun.sleep(10); + } +}