Skip to content
15 changes: 15 additions & 0 deletions .changeset/mighty-squids-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@cloudflare/sandbox": patch
---
Add process readiness detection with port and log pattern waiting
The `Process` object returned by `startProcess()` now includes readiness methods:

- `process.waitForPort(port, options?)`: Wait for process to listen on a port
- Supports HTTP mode (default): checks endpoint returns expected status (200-399)
- Supports TCP mode: checks port accepts connections
- Container-side checking via `/api/port-check` endpoint
- Configurable timeout, interval, path, and expected status

- `process.waitForLog(pattern, options?)`: Wait for pattern in process output
- Supports string or RegExp patterns
- Returns matching line and capture groups
20 changes: 20 additions & 0 deletions packages/sandbox-container/src/handlers/port-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Port Handler
import type {
Logger,
PortCheckRequest,
PortCloseResult,
PortExposeResult,
PortListResult
Expand All @@ -25,6 +26,8 @@ export class PortHandler extends BaseHandler<Request, Response> {

if (pathname === '/api/expose-port') {
return await this.handleExpose(request, context);
} else if (pathname === '/api/port-check') {
return await this.handlePortCheck(request, context);
} else if (pathname === '/api/exposed-ports') {
return await this.handleList(request, context);
} else if (pathname.startsWith('/api/exposed-ports/')) {
Expand All @@ -51,6 +54,23 @@ export class PortHandler extends BaseHandler<Request, Response> {
);
}

private async handlePortCheck(
request: Request,
context: RequestContext
): Promise<Response> {
const body = await this.parseRequestBody<PortCheckRequest>(request);

const result = await this.portService.checkPortReady(body);

return new Response(JSON.stringify(result), {
status: 200,
headers: {
'Content-Type': 'application/json',
...context.corsHeaders
}
});
}

private async handleExpose(
request: Request,
context: RequestContext
Expand Down
7 changes: 7 additions & 0 deletions packages/sandbox-container/src/routes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ export function setupRoutes(router: Router, container: Container): void {
middleware: [container.get('loggingMiddleware')]
});

router.register({
method: 'POST',
path: '/api/port-check',
handler: async (req, ctx) => container.get('portHandler').handle(req, ctx),
middleware: [container.get('loggingMiddleware')]
});

router.register({
method: 'GET',
path: '/api/exposed-ports',
Expand Down
89 changes: 88 additions & 1 deletion packages/sandbox-container/src/services/port-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Port Management Service

import type { Logger } from '@repo/shared';
import type { Logger, PortCheckRequest, PortCheckResponse } from '@repo/shared';
import type {
InvalidPortContext,
PortAlreadyExposedContext,
Expand Down Expand Up @@ -414,6 +414,93 @@ export class PortService {
}
}

/**
* Check if a port is ready to accept connections
* Supports both TCP and HTTP modes
*/
async checkPortReady(request: PortCheckRequest): Promise<PortCheckResponse> {
const {
port,
mode,
path = '/',
statusMin = 200,
statusMax = 399
} = request;

if (mode === 'tcp') {
return this.checkTcpReady(port);
} else {
return this.checkHttpReady(port, path, statusMin, statusMax);
}
}

private async checkTcpReady(port: number): Promise<PortCheckResponse> {
const TCP_TIMEOUT_MS = 5000; // 5 second timeout matching HTTP check

try {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error('TCP connection timeout')),
TCP_TIMEOUT_MS
);
});

const connectPromise = Bun.connect({
hostname: 'localhost',
port,
socket: {
data() {},
open(socket) {
socket.end();
},
error() {},
close() {}
}
});

const socket = await Promise.race([connectPromise, timeoutPromise]);
// Connection succeeded
socket.end();
return { ready: true };
} catch (error) {
return {
ready: false,
error: error instanceof Error ? error.message : 'TCP connection failed'
};
}
}

private async checkHttpReady(
port: number,
path: string,
statusMin: number,
statusMax: number
): Promise<PortCheckResponse> {
try {
const url = `http://localhost:${port}${path.startsWith('/') ? path : `/${path}`}`;
const response = await fetch(url, {
method: 'GET',
signal: AbortSignal.timeout(5000) // 5 second timeout for individual check
});

const statusCode = response.status;
const ready = statusCode >= statusMin && statusCode <= statusMax;

return {
ready,
statusCode,
error: ready
? undefined
: `HTTP status ${statusCode} not in expected range ${statusMin}-${statusMax}`
};
} catch (error) {
return {
ready: false,
error: error instanceof Error ? error.message : 'HTTP request failed'
};
}
}

private startCleanupProcess(): void {
this.cleanupInterval = setInterval(
async () => {
Expand Down
29 changes: 28 additions & 1 deletion packages/sandbox/src/clients/port-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {
PortCheckRequest,
PortCheckResponse,
PortCloseResult,
PortExposeResult,
PortListResult
Expand All @@ -7,7 +9,12 @@ import { BaseHttpClient } from './base-client';
import type { HttpClientOptions } from './types';

// Re-export for convenience
export type { PortExposeResult, PortCloseResult, PortListResult };
export type {
PortExposeResult,
PortCloseResult,
PortListResult,
PortCheckResponse
};

/**
* Request interface for exposing ports
Expand Down Expand Up @@ -102,4 +109,24 @@ export class PortClient extends BaseHttpClient {
throw error;
}
}

/**
* Check if a port is ready to accept connections
* @param request - Port check configuration
*/
async checkPortReady(request: PortCheckRequest): Promise<PortCheckResponse> {
try {
const response = await this.post<PortCheckResponse>(
'/api/port-check',
request
);
return response;
} catch (error) {
// On error (e.g., container not ready), return not ready
return {
ready: false,
error: error instanceof Error ? error.message : 'Port check failed'
};
}
}
}
54 changes: 54 additions & 0 deletions packages/sandbox/src/errors/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import type {
PortErrorContext,
PortNotExposedContext,
ProcessErrorContext,
ProcessExitedBeforeReadyContext,
ProcessNotFoundContext,
ProcessReadyTimeoutContext,
ValidationFailedContext
} from '@repo/shared/errors';

Expand Down Expand Up @@ -592,3 +594,55 @@ export class ValidationFailedError extends SandboxError<ValidationFailedContext>
return this.context.validationErrors;
}
}

// ============================================================================
// Process Readiness Errors
// ============================================================================

/**
* Error thrown when a process does not become ready within the timeout period
*/
export class ProcessReadyTimeoutError extends SandboxError<ProcessReadyTimeoutContext> {
constructor(errorResponse: ErrorResponse<ProcessReadyTimeoutContext>) {
super(errorResponse);
this.name = 'ProcessReadyTimeoutError';
}

// Type-safe accessors
get processId() {
return this.context.processId;
}
get command() {
return this.context.command;
}
get condition() {
return this.context.condition;
}
get timeout() {
return this.context.timeout;
}
}

/**
* Error thrown when a process exits before becoming ready
*/
export class ProcessExitedBeforeReadyError extends SandboxError<ProcessExitedBeforeReadyContext> {
constructor(errorResponse: ErrorResponse<ProcessExitedBeforeReadyContext>) {
super(errorResponse);
this.name = 'ProcessExitedBeforeReadyError';
}

// Type-safe accessors
get processId() {
return this.context.processId;
}
get command() {
return this.context.command;
}
get condition() {
return this.context.condition;
}
get exitCode() {
return this.context.exitCode;
}
}
5 changes: 5 additions & 0 deletions packages/sandbox/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ export type {
PortErrorContext,
PortNotExposedContext,
ProcessErrorContext,
ProcessExitedBeforeReadyContext,
ProcessNotFoundContext,
ProcessReadyTimeoutContext,
ValidationFailedContext
} from '@repo/shared/errors';
// Re-export shared types and constants
Expand Down Expand Up @@ -100,8 +102,11 @@ export {
PortInUseError,
PortNotExposedError,
ProcessError,
// Process Readiness Errors
ProcessExitedBeforeReadyError,
// Process Errors
ProcessNotFoundError,
ProcessReadyTimeoutError,
SandboxError,
ServiceNotRespondingError,
// Validation Errors
Expand Down
10 changes: 9 additions & 1 deletion packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ export type {
RunCodeOptions,
SandboxOptions,
SessionOptions,
StreamOptions
StreamOptions,
// Process readiness types
WaitForLogResult,
WaitForPortOptions
} from '@repo/shared';
// Export type guards for runtime validation
export { isExecResult, isProcess, isProcessStatus } from '@repo/shared';
Expand Down Expand Up @@ -96,6 +99,11 @@ export type {
ExecutionCallbacks,
InterpreterClient
} from './clients/interpreter-client.js';
// Export process readiness errors
export {
ProcessExitedBeforeReadyError,
ProcessReadyTimeoutError
} from './errors';
// Export file streaming utilities for binary file support
export { collectFile, streamFile } from './file-stream';
// Export interpreter functionality
Expand Down
Loading
Loading