From 39d6f61cbbe67f5e77199ad61950010fc4f94ec0 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 3 Dec 2025 21:04:53 +0000 Subject: [PATCH 01/12] Introduced a new `/server` endpoint to start a server and return its preview URL; added `waitFor` method to processes, allowing them to wait for specific conditions (log patterns or port availability) --- packages/sandbox/README.md | 53 +- packages/sandbox/src/errors/classes.ts | 66 ++ packages/sandbox/src/errors/index.ts | 5 + packages/sandbox/src/index.ts | 15 +- packages/sandbox/src/sandbox.ts | 394 +++++++- .../sandbox/tests/process-readiness.test.ts | 847 ++++++++++++++++++ packages/shared/src/errors/codes.ts | 4 + packages/shared/src/errors/contexts.ts | 21 + packages/shared/src/errors/index.ts | 2 + packages/shared/src/errors/status-map.ts | 4 + packages/shared/src/index.ts | 5 + packages/shared/src/types.ts | 82 ++ tests/e2e/process-readiness-workflow.test.ts | 475 ++++++++++ tests/e2e/test-worker/index.ts | 63 +- 14 files changed, 2031 insertions(+), 5 deletions(-) create mode 100644 packages/sandbox/tests/process-readiness.test.ts create mode 100644 tests/e2e/process-readiness-workflow.test.ts diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index d17e1fbc..d98b8f53 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -92,11 +92,61 @@ export default { return Response.json({ content: file.content }); } - return new Response('Try /run or /file'); + // Start a server and wait for it to be ready + if (url.pathname === '/server') { + await sandbox.writeFile( + '/workspace/server.js', + ` + const server = Bun.serve({ + port: 8080, + fetch() { return new Response("Hello from sandbox!"); } + }); + console.log("Server ready on port 8080"); + ` + ); + + const { url: previewUrl, process } = await sandbox.serve( + 'bun run /workspace/server.js', + { port: 8080, hostname: url.hostname } + ); + + return Response.json({ previewUrl, processId: process.id }); + } + + return new Response('Try /run, /file, or /server'); } }; ``` +## Process Readiness + +Wait for processes to be ready before proceeding. Three patterns available: + +```typescript +// Pattern 1: Inline readiness - blocks until pattern appears (recommended) +const proc = await sandbox.startProcess('npm start', { + ready: 'Server listening on port 3000', + readyTimeout: 30000 +}); + +// Pattern 2: Sequential waits - for multiple conditions +const proc = await sandbox.startProcess('npm start'); +await proc.waitFor('Database connected'); +await proc.waitFor(3000); // Wait for port 3000 to be available + +// Pattern 3: Server shorthand - start, wait, and expose in one call +const { url, process } = await sandbox.serve('npm start', { + port: 3000, + hostname: 'example.com' +}); +``` + +Conditions can be: + +- **String** - Waits for substring in stdout/stderr +- **RegExp** - Waits for pattern match (returns capture groups) +- **Number** - Waits for port to be available + ## Documentation **📖 [Full Documentation](https://developers.cloudflare.com/sandbox/)** @@ -113,6 +163,7 @@ export default { - **Code Interpreter** - Execute Python and JavaScript with rich outputs - **File System Access** - Read, write, and manage files - **Command Execution** - Run any command with streaming support +- **Process Readiness** - Wait for processes to be ready before proceeding - **Preview URLs** - Expose services with public URLs - **Git Integration** - Clone repositories directly diff --git a/packages/sandbox/src/errors/classes.ts b/packages/sandbox/src/errors/classes.ts index b36ea05d..2d8961df 100644 --- a/packages/sandbox/src/errors/classes.ts +++ b/packages/sandbox/src/errors/classes.ts @@ -25,7 +25,9 @@ import type { PortErrorContext, PortNotExposedContext, ProcessErrorContext, + ProcessExitedBeforeReadyContext, ProcessNotFoundContext, + ProcessReadyTimeoutContext, ValidationFailedContext } from '@repo/shared/errors'; @@ -592,3 +594,67 @@ export class ValidationFailedError extends SandboxError 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 { + constructor(errorResponse: ErrorResponse) { + 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; + } + get stdout() { + return this.context.stdout; + } + get stderr() { + return this.context.stderr; + } +} + +/** + * Error thrown when a process exits before becoming ready + */ +export class ProcessExitedBeforeReadyError extends SandboxError { + constructor(errorResponse: ErrorResponse) { + 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; + } + get stdout() { + return this.context.stdout; + } + get stderr() { + return this.context.stderr; + } +} diff --git a/packages/sandbox/src/errors/index.ts b/packages/sandbox/src/errors/index.ts index f8f34041..98936962 100644 --- a/packages/sandbox/src/errors/index.ts +++ b/packages/sandbox/src/errors/index.ts @@ -61,7 +61,9 @@ export type { PortErrorContext, PortNotExposedContext, ProcessErrorContext, + ProcessExitedBeforeReadyContext, ProcessNotFoundContext, + ProcessReadyTimeoutContext, ValidationFailedContext } from '@repo/shared/errors'; // Re-export shared types and constants @@ -100,8 +102,11 @@ export { PortInUseError, PortNotExposedError, ProcessError, + // Process Readiness Errors + ProcessExitedBeforeReadyError, // Process Errors ProcessNotFoundError, + ProcessReadyTimeoutError, SandboxError, ServiceNotRespondingError, // Validation Errors diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 5f9a56c7..8617e188 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -37,10 +37,14 @@ export type { Process, ProcessOptions, ProcessStatus, + // Process readiness types + ReadyCondition, RunCodeOptions, SandboxOptions, + ServeOptions, SessionOptions, - StreamOptions + StreamOptions, + WaitForResult } from '@repo/shared'; // Export type guards for runtime validation export { isExecResult, isProcess, isProcessStatus } from '@repo/shared'; @@ -96,6 +100,15 @@ export type { ExecutionCallbacks, InterpreterClient } from './clients/interpreter-client.js'; +export type { + ProcessExitedBeforeReadyContext, + ProcessReadyTimeoutContext +} from './errors'; +// 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 diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index b5014691..534f6cb7 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -10,14 +10,18 @@ import type { ExecutionResult, ExecutionSession, ISandbox, + LogEvent, MountBucketOptions, Process, ProcessOptions, ProcessStatus, + ReadyCondition, RunCodeOptions, SandboxOptions, + ServeOptions, SessionOptions, - StreamOptions + StreamOptions, + WaitForResult } from '@repo/shared'; import { createLogger, @@ -28,7 +32,12 @@ import { } from '@repo/shared'; import { type ExecuteResponse, SandboxClient } from './clients'; import type { ErrorResponse } from './errors'; -import { CustomDomainRequiredError, ErrorCode } from './errors'; +import { + CustomDomainRequiredError, + ErrorCode, + ProcessExitedBeforeReadyError, + ProcessReadyTimeoutError +} from './errors'; import { CodeInterpreter } from './interpreter'; import { isLocalhostPattern } from './request-handler'; import { SecurityError, sanitizeSandboxId, validatePort } from './security'; @@ -1239,10 +1248,331 @@ export class Sandbox extends Container implements ISandbox { getLogs: async () => { const logs = await this.getProcessLogs(data.id); return { stdout: logs.stdout, stderr: logs.stderr }; + }, + + waitFor: async ( + condition: ReadyCondition, + timeout?: number + ): Promise => { + return this.waitForCondition(data.id, data.command, condition, timeout); } }; } + /** + * Wait for a condition to be met for a process + * Supports log patterns (string/regex) and port availability + */ + private async waitForCondition( + processId: string, + command: string, + condition: ReadyCondition, + timeout: number = 30_000 + ): Promise { + const startTime = Date.now(); + const conditionStr = this.conditionToString(condition); + + // For port-based waiting + if (typeof condition === 'number') { + return this.waitForPortReady(processId, command, condition, timeout); + } + + // For log-based waiting (string or regex) + return this.waitForLog(processId, command, condition, timeout); + } + + /** + * Wait for a log pattern to appear in process output + */ + private async waitForLog( + processId: string, + command: string, + pattern: string | RegExp, + timeout: number + ): Promise { + const startTime = Date.now(); + const conditionStr = this.conditionToString(pattern); + let collectedStdout = ''; + let collectedStderr = ''; + + // First check existing logs + try { + const existingLogs = await this.getProcessLogs(processId); + collectedStdout = existingLogs.stdout; + collectedStderr = existingLogs.stderr; + + // Check stdout + const stdoutResult = this.matchPattern(existingLogs.stdout, pattern); + if (stdoutResult) { + return stdoutResult; + } + + // Check stderr + const stderrResult = this.matchPattern(existingLogs.stderr, pattern); + if (stderrResult) { + return stderrResult; + } + } catch (error) { + // Process might have already exited, continue to streaming + this.logger.debug('Could not get existing logs, will stream', { + processId, + error: error instanceof Error ? error.message : String(error) + }); + } + + // Stream new logs and check for pattern + const stream = await this.streamProcessLogs(processId); + + try { + for await (const event of parseSSEStream(stream)) { + // Check timeout + if (Date.now() - startTime > timeout) { + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout, + collectedStdout, + collectedStderr + ); + } + + // Handle different event types + if (event.type === 'stdout' || event.type === 'stderr') { + const data = event.data || ''; + + if (event.type === 'stdout') { + collectedStdout += data; + } else { + collectedStderr += data; + } + + // Check for pattern match + const result = this.matchPattern(data, pattern); + if (result) { + return result; + } + } + + // Process exited + if (event.type === 'exit') { + throw this.createExitedBeforeReadyError( + processId, + command, + conditionStr, + event.exitCode ?? 1, + collectedStdout, + collectedStderr + ); + } + } + } catch (error) { + // Re-throw our custom errors + if ( + error instanceof ProcessReadyTimeoutError || + error instanceof ProcessExitedBeforeReadyError + ) { + throw error; + } + + // Check if it's a timeout + if (Date.now() - startTime > timeout) { + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout, + collectedStdout, + collectedStderr + ); + } + + throw error; + } + + // Stream ended without finding pattern + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout, + collectedStdout, + collectedStderr + ); + } + + /** + * Wait for a port to become available (for process readiness checking) + */ + private async waitForPortReady( + processId: string, + command: string, + port: number, + timeout: number + ): Promise { + const startTime = Date.now(); + const conditionStr = `port ${port}`; + const pollInterval = 500; // Check every 500ms + + while (Date.now() - startTime < timeout) { + // Check if process is still running + const processInfo = await this.getProcess(processId); + if ( + !processInfo || + processInfo.status === 'completed' || + processInfo.status === 'failed' || + processInfo.status === 'killed' + ) { + const logs = await this.getProcessLogs(processId).catch(() => ({ + stdout: '', + stderr: '' + })); + throw this.createExitedBeforeReadyError( + processId, + command, + conditionStr, + processInfo?.exitCode ?? 1, + logs.stdout, + logs.stderr + ); + } + + // Try to connect to the port using nc + try { + const result = await this.exec(`nc -z localhost ${port}`, { + timeout: 1000 + }); + if (result.exitCode === 0) { + return {}; // Port is available + } + } catch { + // Port not ready yet, continue polling + } + + // Wait before next check + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + // Timeout + const logs = await this.getProcessLogs(processId).catch(() => ({ + stdout: '', + stderr: '' + })); + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout, + logs.stdout, + logs.stderr + ); + } + + /** + * Match a pattern against text + */ + private matchPattern( + text: string, + pattern: string | RegExp + ): WaitForResult | null { + if (typeof pattern === 'string') { + // Simple substring match + if (text.includes(pattern)) { + // Find the line containing the pattern + const lines = text.split('\n'); + for (const line of lines) { + if (line.includes(pattern)) { + return { line }; + } + } + return { line: pattern }; + } + } else { + // Regex match + const match = text.match(pattern); + if (match) { + // Find the full line containing the match + const lines = text.split('\n'); + for (const line of lines) { + if (pattern.test(line)) { + return { line, match: line.match(pattern) || undefined }; + } + } + return { line: match[0], match }; + } + } + return null; + } + + /** + * Convert a condition to a human-readable string + */ + private conditionToString(condition: ReadyCondition): string { + if (typeof condition === 'string') { + return `"${condition}"`; + } else if (typeof condition === 'number') { + return `port ${condition}`; + } else { + return condition.toString(); + } + } + + /** + * Create a ProcessReadyTimeoutError + */ + private createReadyTimeoutError( + processId: string, + command: string, + condition: string, + timeout: number, + stdout: string, + stderr: string + ): ProcessReadyTimeoutError { + return new ProcessReadyTimeoutError({ + code: ErrorCode.PROCESS_READY_TIMEOUT, + message: `Process did not become ready within ${timeout}ms. Waiting for: ${condition}`, + context: { + processId, + command, + condition, + timeout, + stdout: stdout.slice(-2000), // Last 2000 chars + stderr: stderr.slice(-2000) + }, + httpStatus: 408, + timestamp: new Date().toISOString(), + suggestion: `Check if your process outputs ${condition}. You can increase the timeout with { readyTimeout: ${timeout * 2} }` + }); + } + + /** + * Create a ProcessExitedBeforeReadyError + */ + private createExitedBeforeReadyError( + processId: string, + command: string, + condition: string, + exitCode: number, + stdout: string, + stderr: string + ): ProcessExitedBeforeReadyError { + return new ProcessExitedBeforeReadyError({ + code: ErrorCode.PROCESS_EXITED_BEFORE_READY, + message: `Process exited with code ${exitCode} before becoming ready. Waiting for: ${condition}`, + context: { + processId, + command, + condition, + exitCode, + stdout: stdout.slice(-2000), + stderr: stderr.slice(-2000) + }, + httpStatus: 500, + timestamp: new Date().toISOString(), + suggestion: 'Check the process output above for error messages' + }); + } + // Background process management async startProcess( command: string, @@ -1289,6 +1619,12 @@ export class Sandbox extends Container implements ISandbox { options.onStart(processObj); } + // If ready condition is specified, wait for it before returning + if (options?.ready !== undefined) { + const readyTimeout = options.readyTimeout ?? 30_000; + await processObj.waitFor(options.ready, readyTimeout); + } + return processObj; } catch (error) { if (options?.onError && error instanceof Error) { @@ -1299,6 +1635,60 @@ export class Sandbox extends Container implements ISandbox { } } + /** + * Start a server process and wait for it to be ready + * Returns the preview URL directly for simple cases + * + * @example + * // Simple usage - get URL directly + * const url = await sandbox.serve("npm run dev", 3000); + * + * // With options - get full service object + * const { url, process } = await sandbox.serve("npm run dev", { + * port: 3000, + * hostname: "app.example.com", + * ready: /listening/ + * }); + */ + async serve( + command: string, + portOrOptions: number | ServeOptions + ): Promise { + const options: ServeOptions = + typeof portOrOptions === 'number' + ? { port: portOrOptions } + : portOrOptions; + + const { port, hostname, ready, timeout = 60_000, env, cwd } = options; + + // Start the process with port-based readiness by default + // If a ready pattern is also provided, we'll check both + const processOptions: ProcessOptions = { + ready: ready ?? port, // Default to port check if no pattern specified + readyTimeout: timeout, + env, + cwd + }; + + const proc = await this.startProcess(command, processOptions); + + // If both ready pattern AND port were specified, also wait for port + if (ready !== undefined && typeof ready !== 'number') { + // Pattern was specified, now also check port + await proc.waitFor(port, timeout); + } + + // If hostname is provided, expose the port and return full object + if (hostname) { + const { url } = await this.exposePort(port, { hostname }); + return { url, process: proc }; + } + + // No hostname - just return a placeholder URL indicating port is ready + // The user would need to call exposePort separately for a real URL + return `http://localhost:${port}`; + } + async listProcesses(sessionId?: string): Promise { const session = sessionId ?? (await this.ensureDefaultSession()); const response = await this.client.processes.listProcesses(); diff --git a/packages/sandbox/tests/process-readiness.test.ts b/packages/sandbox/tests/process-readiness.test.ts new file mode 100644 index 00000000..bb0e54fc --- /dev/null +++ b/packages/sandbox/tests/process-readiness.test.ts @@ -0,0 +1,847 @@ +/** + * Unit tests for process readiness feature + * + * Tests the waitFor(), serve(), and startProcess({ ready }) functionality + */ + +import type { DurableObjectState } from '@cloudflare/workers-types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ProcessExitedBeforeReadyError, + ProcessReadyTimeoutError +} from '../src/errors'; +import { Sandbox } from '../src/sandbox'; + +// Mock dependencies +vi.mock('./interpreter', () => ({ + CodeInterpreter: vi.fn().mockImplementation(() => ({})) +})); + +vi.mock('@cloudflare/containers', () => { + const MockContainer = class Container { + ctx: any; + env: any; + constructor(ctx: any, env: any) { + this.ctx = ctx; + this.env = env; + } + async fetch(): Promise { + return new Response('Mock Container fetch'); + } + async containerFetch(): Promise { + return new Response('Mock Container HTTP fetch'); + } + async getState() { + return { status: 'healthy' }; + } + }; + + return { + Container: MockContainer, + getContainer: vi.fn(), + switchPort: vi.fn() + }; +}); + +describe('Process Readiness Feature', () => { + let sandbox: Sandbox; + let mockCtx: Partial>; + let mockEnv: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockCtx = { + storage: { + get: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue(new Map()) + } as any, + blockConcurrencyWhile: vi + .fn() + .mockImplementation( + (callback: () => Promise): Promise => callback() + ), + waitUntil: vi.fn(), + id: { + toString: () => 'test-sandbox-id', + equals: vi.fn(), + name: 'test-sandbox' + } as any + }; + + mockEnv = {}; + + sandbox = new Sandbox(mockCtx as DurableObjectState<{}>, mockEnv); + + await vi.waitFor(() => { + expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled(); + }); + + // Mock session creation + vi.spyOn(sandbox.client.utils, 'createSession').mockResolvedValue({ + success: true, + id: 'sandbox-default', + message: 'Created' + } as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('waitFor() method', () => { + describe('string pattern matching', () => { + it('should resolve when string pattern found in existing logs', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: + 'Compiling...\nServer listening on port 3000\nReady to accept connections', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const proc = await sandbox.startProcess('npm start'); + const result = await proc.waitFor('Server listening on port 3000'); + + expect(result.line).toContain('Server listening on port 3000'); + }); + + it('should find pattern via streaming when not in historical logs', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + // First call returns logs without the pattern + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Starting server...', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + // Mock streaming to emit the pattern + const sseData = `data: {"type":"stdout","data":"Server ready on port 3000\\n","timestamp":"${new Date().toISOString()}"} + +`; + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sseData)); + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + const result = await proc.waitFor('Server ready on port 3000'); + + expect(result.line).toBe('Server ready on port 3000'); + }); + }); + + describe('regex pattern matching', () => { + it('should resolve with match details when regex matches', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Server listening on port 8080', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const proc = await sandbox.startProcess('npm start'); + const result = await proc.waitFor(/port (\d+)/); + + expect(result.match).toBeDefined(); + expect(result.match![0]).toBe('port 8080'); + expect(result.match![1]).toBe('8080'); + }); + }); + + describe('port readiness', () => { + it('should wait for port to become available', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({ + success: true, + stdout: '', + stderr: '', + exitCode: 0, + command: 'nc -z localhost 3000', + timestamp: new Date().toISOString() + } as any); + + const proc = await sandbox.startProcess('npm start'); + const result = await proc.waitFor(3000); + + expect(result).toEqual({}); + expect(sandbox.client.commands.execute).toHaveBeenCalledWith( + 'nc -z localhost 3000', + expect.any(String), + expect.objectContaining({ timeoutMs: 1000 }) + ); + }); + }); + + describe('timeout handling', () => { + it('should throw ProcessReadyTimeoutError when pattern not found', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Starting...', + stderr: 'Warning: something', + timestamp: new Date().toISOString() + } as any); + + // Create an empty stream that closes immediately + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + + await expect(proc.waitFor('never-appears', 100)).rejects.toThrow( + ProcessReadyTimeoutError + ); + }); + + it('should include captured logs in timeout error', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Output line 1\nOutput line 2', + stderr: 'Error occurred', + timestamp: new Date().toISOString() + } as any); + + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitFor('never-found', 100); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessReadyTimeoutError); + const readyError = error as ProcessReadyTimeoutError; + expect(readyError.stdout).toContain('Output line 1'); + expect(readyError.stderr).toContain('Error occurred'); + expect(readyError.processId).toBe('proc-server'); + expect(readyError.command).toBe('npm start'); + } + }); + }); + + describe('process exit handling', () => { + it('should throw ProcessExitedBeforeReadyError when process exits', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Starting...', + stderr: 'Error: port in use', + timestamp: new Date().toISOString() + } as any); + + // Mock stream to emit an exit event + const sseData = `data: {"type":"stdout","data":"Starting...\\n","timestamp":"${new Date().toISOString()}"} + +data: {"type":"exit","exitCode":1,"timestamp":"${new Date().toISOString()}"} + +`; + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sseData)); + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + + await expect(proc.waitFor('Server ready')).rejects.toThrow( + ProcessExitedBeforeReadyError + ); + }); + + it('should include exit code and logs in exit error', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: '', + stderr: 'command not found: npm', + timestamp: new Date().toISOString() + } as any); + + // Mock stream to emit exit event with specific exit code + const sseData = `data: {"type":"stderr","data":"command not found: npm\\n","timestamp":"${new Date().toISOString()}"} + +data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} + +`; + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sseData)); + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitFor('Server ready'); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); + const exitError = error as ProcessExitedBeforeReadyError; + expect(exitError.exitCode).toBe(127); + expect(exitError.stderr).toContain('command not found'); + expect(exitError.processId).toBe('proc-server'); + } + }); + }); + }); + + describe('startProcess() with ready option', () => { + it('should wait for ready pattern before returning', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Server ready', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const proc = await sandbox.startProcess('npm start', { + ready: 'Server ready' + }); + + expect(proc.id).toBe('proc-server'); + expect(sandbox.client.processes.getProcessLogs).toHaveBeenCalled(); + }); + + it('should respect readyTimeout option', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Starting...', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + } + }); + + vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue( + mockStream + ); + + await expect( + sandbox.startProcess('npm start', { + ready: 'never-appears', + readyTimeout: 50 + }) + ).rejects.toThrow(ProcessReadyTimeoutError); + }); + }); + + describe('serve() method', () => { + beforeEach(async () => { + await sandbox.setSandboxName('test-sandbox'); + + vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({ + success: true, + port: 8080, + url: '', + timestamp: new Date().toISOString() + } as any); + }); + + it('should start process, wait for port, and expose it', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + // Port check succeeds + vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({ + success: true, + stdout: '', + stderr: '', + exitCode: 0, + command: 'nc -z localhost 8080', + timestamp: new Date().toISOString() + } as any); + + const result = await sandbox.serve('npm start', { + port: 8080, + hostname: 'example.com' + }); + + expect(result.process.id).toBe('proc-server'); + expect(result.url).toContain('example.com'); + expect(sandbox.client.processes.startProcess).toHaveBeenCalled(); + expect(sandbox.client.ports.exposePort).toHaveBeenCalled(); + }); + + it('should use custom ready pattern when provided', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + // Pattern found in logs immediately + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Custom ready message', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + // Port check also needed since serve() checks both pattern AND port when ready is provided + vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({ + success: true, + stdout: '', + stderr: '', + exitCode: 0, + command: 'nc -z localhost 8080', + timestamp: new Date().toISOString() + } as any); + + const result = await sandbox.serve('npm start', { + port: 8080, + hostname: 'example.com', + ready: 'Custom ready message' + }); + + expect(result.process.id).toBe('proc-server'); + expect(sandbox.client.processes.getProcessLogs).toHaveBeenCalled(); + }); + + it('should pass environment variables to process', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({ + success: true, + stdout: '', + stderr: '', + exitCode: 0, + command: 'nc -z localhost 8080', + timestamp: new Date().toISOString() + } as any); + + await sandbox.serve('npm start', { + port: 8080, + hostname: 'example.com', + env: { NODE_ENV: 'production' } + }); + + expect(sandbox.client.processes.startProcess).toHaveBeenCalledWith( + 'npm start', + expect.any(String), + expect.objectContaining({ + env: { NODE_ENV: 'production' } + }) + ); + }); + }); + + describe('conditionToString helper', () => { + it('should format string conditions as quoted strings', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'no match here', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + } + }); + + vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue( + mockStream + ); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitFor('Server ready', 50); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessReadyTimeoutError); + const readyError = error as ProcessReadyTimeoutError; + expect(readyError.condition).toBe('"Server ready"'); + } + }); + + it('should format regex conditions with regex syntax', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'no match here', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + } + }); + + vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue( + mockStream + ); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitFor(/port \d+/, 50); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessReadyTimeoutError); + const readyError = error as ProcessReadyTimeoutError; + expect(readyError.condition).toBe('/port \\d+/'); + } + }); + + it('should format port conditions as port numbers', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'completed', + exitCode: 1, + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: '', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitFor(3000); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); + const exitError = error as ProcessExitedBeforeReadyError; + expect(exitError.condition).toBe('port 3000'); + } + }); + }); +}); diff --git a/packages/shared/src/errors/codes.ts b/packages/shared/src/errors/codes.ts index db98c095..d2b2bba3 100644 --- a/packages/shared/src/errors/codes.ts +++ b/packages/shared/src/errors/codes.ts @@ -97,6 +97,10 @@ export const ErrorCode = { // Code Interpreter Errors (501) - Feature not available in image variant PYTHON_NOT_AVAILABLE: 'PYTHON_NOT_AVAILABLE', + // Process Readiness Errors (408/500) + PROCESS_READY_TIMEOUT: 'PROCESS_READY_TIMEOUT', + PROCESS_EXITED_BEFORE_READY: 'PROCESS_EXITED_BEFORE_READY', + // Validation Errors (400) VALIDATION_FAILED: 'VALIDATION_FAILED', diff --git a/packages/shared/src/errors/contexts.ts b/packages/shared/src/errors/contexts.ts index 2b0ed705..bcb70795 100644 --- a/packages/shared/src/errors/contexts.ts +++ b/packages/shared/src/errors/contexts.ts @@ -48,6 +48,27 @@ export interface ProcessErrorContext { stderr?: string; } +/** + * Process readiness error contexts + */ +export interface ProcessReadyTimeoutContext { + processId: string; + command: string; + condition: string; + timeout: number; + stdout?: string; + stderr?: string; +} + +export interface ProcessExitedBeforeReadyContext { + processId: string; + command: string; + condition: string; + exitCode: number; + stdout?: string; + stderr?: string; +} + /** * Port error contexts */ diff --git a/packages/shared/src/errors/index.ts b/packages/shared/src/errors/index.ts index 84d9c666..b294aff8 100644 --- a/packages/shared/src/errors/index.ts +++ b/packages/shared/src/errors/index.ts @@ -54,7 +54,9 @@ export type { PortErrorContext, PortNotExposedContext, ProcessErrorContext, + ProcessExitedBeforeReadyContext, ProcessNotFoundContext, + ProcessReadyTimeoutContext, ValidationFailedContext } from './contexts'; // Export utility functions diff --git a/packages/shared/src/errors/status-map.ts b/packages/shared/src/errors/status-map.ts index 7e9dd8ca..d71f1ab6 100644 --- a/packages/shared/src/errors/status-map.ts +++ b/packages/shared/src/errors/status-map.ts @@ -53,7 +53,11 @@ export const ERROR_STATUS_MAP: Record = { // 503 Service Unavailable [ErrorCode.INTERPRETER_NOT_READY]: 503, + // 408 Request Timeout + [ErrorCode.PROCESS_READY_TIMEOUT]: 408, + // 500 Internal Server Error + [ErrorCode.PROCESS_EXITED_BEFORE_READY]: 500, [ErrorCode.NO_SPACE]: 500, [ErrorCode.TOO_MANY_FILES]: 500, [ErrorCode.TOO_MANY_LINKS]: 500, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d4367084..296472c6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -94,15 +94,20 @@ export type { ProcessStartResult, ProcessStatus, ReadFileResult, + // Process readiness types + ReadyCondition, RenameFileResult, // Sandbox configuration options SandboxOptions, + // Serve options + ServeOptions, // Session management result types SessionCreateResult, SessionDeleteResult, SessionOptions, ShutdownResult, StreamOptions, + WaitForResult, WriteFileResult } from './types.js'; export { isExecResult, isProcess, isProcessStatus } from './types.js'; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 50238211..f086a163 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -100,6 +100,50 @@ export interface ExecResult { sessionId?: string; } +/** + * Condition to wait for before considering a process "ready" + * - string: Wait for this substring in logs + * - RegExp: Wait for this pattern in logs + * - number: Wait for this port to accept connections + */ +export type ReadyCondition = string | RegExp | number; + +/** + * Result from waiting for a condition + */ +export interface WaitForResult { + /** The log line that matched (for string/regex conditions) */ + line?: string; + /** Regex capture groups (if condition was a RegExp) */ + match?: RegExpMatchArray; +} + +/** + * Options for the serve() method + */ +export interface ServeOptions { + /** Port to wait for and optionally expose */ + port: number; + + /** Hostname for preview URL (required for URL generation) */ + hostname?: string; + + /** + * Additional ready condition (log pattern) + * If specified, waits for BOTH the log pattern AND the port + */ + ready?: string | RegExp; + + /** Timeout in milliseconds (default: 60000) */ + timeout?: number; + + /** Environment variables for the process */ + env?: Record; + + /** Working directory for the process */ + cwd?: string; +} + // Background process types export interface ProcessOptions extends BaseExecOptions { /** @@ -132,6 +176,30 @@ export interface ProcessOptions extends BaseExecOptions { * Callback for process errors */ onError?: (error: Error) => void; + + /** + * Wait for process to be "ready" before resolving + * - string: Wait for this substring in logs + * - RegExp: Wait for this pattern in logs + * - number: Wait for this port to accept connections + * + * @example + * // Wait for log message + * await sandbox.startProcess("npm run dev", { ready: "listening" }); + * + * // Wait for regex pattern + * await sandbox.startProcess("npm run dev", { ready: /port (\d+)/ }); + * + * // Wait for port + * await sandbox.startProcess("npm run dev", { ready: 3000 }); + */ + ready?: ReadyCondition; + + /** + * Timeout for readiness check in milliseconds (default: 30000) + * Only used when `ready` option is specified + */ + readyTimeout?: number; } export type ProcessStatus = @@ -197,6 +265,20 @@ export interface Process { * Get accumulated logs */ getLogs(): Promise<{ stdout: string; stderr: string }>; + + /** + * Wait for a condition to be met + * - string: Wait for this substring in logs + * - RegExp: Wait for this pattern in logs + * - number: Wait for this port to accept connections + * + * @example + * const proc = await sandbox.startProcess("python train.py"); + * await proc.waitFor("Epoch 1 complete"); + * await proc.waitFor(/Epoch (\d+) complete/); + * await proc.waitFor(8080); // Wait for port + */ + waitFor(condition: ReadyCondition, timeout?: number): Promise; } // Streaming event types diff --git a/tests/e2e/process-readiness-workflow.test.ts b/tests/e2e/process-readiness-workflow.test.ts new file mode 100644 index 00000000..47085462 --- /dev/null +++ b/tests/e2e/process-readiness-workflow.test.ts @@ -0,0 +1,475 @@ +import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; +import { + createSandboxId, + createTestHeaders, + cleanupSandbox +} from './helpers/test-fixtures'; +import type { Process, WaitForResult, PortExposeResult } from '@repo/shared'; + +// Port exposure tests require custom domain with wildcard DNS routing +const skipPortExposureTests = + process.env.TEST_WORKER_URL?.endsWith('.workers.dev') ?? false; + +/** + * Process Readiness Workflow Integration Tests + * + * Tests the process readiness feature including: + * - waitFor() method with string patterns + * - waitFor() method with port checking + * - startProcess() with ready option + * - serve() method for server processes + */ +describe('Process Readiness Workflow', () => { + describe('local', () => { + let runner: WranglerDevRunner | null = null; + let workerUrl: string; + let currentSandboxId: string | null = null; + + beforeAll(async () => { + const result = await getTestWorkerUrl(); + workerUrl = result.url; + runner = result.runner; + }); + + afterEach(async () => { + if (currentSandboxId) { + await cleanupSandbox(workerUrl, currentSandboxId); + currentSandboxId = null; + } + }); + + afterAll(async () => { + if (runner) { + await runner.stop(); + } + }); + + test('should wait for string pattern in process output', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a script that outputs a specific message after a delay + const scriptCode = ` +console.log("Starting up..."); +await Bun.sleep(500); +console.log("Server ready on port 8080"); +await Bun.sleep(60000); // Keep running + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/server.js', + content: scriptCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/server.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for the pattern + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitFor`, + { + method: 'POST', + headers, + body: JSON.stringify({ + condition: 'Server ready on port 8080', + timeout: 10000 + }) + } + ); + + expect(waitResponse.status).toBe(200); + const waitData = (await waitResponse.json()) as WaitForResult; + expect(waitData.line).toContain('Server ready on port 8080'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); + + test('should wait for port to become available', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a Bun server that listens on a port + const serverCode = ` +const server = Bun.serve({ + port: 9090, + fetch(req) { + return new Response("OK"); + }, +}); +console.log("Server started"); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/portserver.js', + content: serverCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/portserver.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for port 9090 to be available + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitFor`, + { + method: 'POST', + headers, + body: JSON.stringify({ + condition: 9090, + timeout: 15000 + }) + } + ); + + expect(waitResponse.status).toBe(200); + + // Verify the port is actually listening by trying to curl it + const verifyResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'curl -s http://localhost:9090' + }) + }); + + const verifyData = (await verifyResponse.json()) as { stdout: string }; + expect(verifyData.stdout).toBe('OK'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); + + test('should start process with ready option and block until ready', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a script with delayed ready message + const scriptCode = ` +console.log("Initializing..."); +await Bun.sleep(1000); +console.log("Database connected"); +await Bun.sleep(500); +console.log("Ready to serve requests"); +await Bun.sleep(60000); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/app.js', + content: scriptCode + }) + }); + + // Start process with ready option - should block until pattern appears + const startTime = Date.now(); + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/app.js', + ready: 'Ready to serve requests', + readyTimeout: 10000 + }) + }); + const duration = Date.now() - startTime; + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + + // Should have waited at least 1.5 seconds for the delayed output + expect(duration).toBeGreaterThan(1000); + + // Process should be running + expect(startData.status).toBe('running'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${startData.id}`, { + method: 'DELETE', + headers + }); + }, 60000); + + test('should fail with timeout error if pattern never appears', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a script that never outputs the expected pattern + const scriptCode = ` +console.log("Starting..."); +console.log("Still starting..."); +await Bun.sleep(60000); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/slow.js', + content: scriptCode + }) + }); + + // Start process with short timeout + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/slow.js', + ready: 'Server ready', + readyTimeout: 2000 + }) + }); + + // Should fail with timeout + expect(startResponse.status).toBe(500); + const errorData = (await startResponse.json()) as { error: string }; + expect(errorData.error).toMatch(/timeout|did not become ready/i); + }, 60000); + + test('should fail with error if process exits before becoming ready', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Start a process that exits immediately + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "quick exit"', + ready: 'Server ready', + readyTimeout: 10000 + }) + }); + + // Should fail because process exits before pattern appears + expect(startResponse.status).toBe(500); + const errorData = (await startResponse.json()) as { error: string }; + expect(errorData.error).toMatch( + /exited|exit|timeout|did not become ready/i + ); + }, 60000); + + test.skipIf(skipPortExposureTests)( + 'should serve a process and expose port automatically', + async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a simple HTTP server + const serverCode = ` +const server = Bun.serve({ + port: 8080, + fetch(req) { + return new Response(JSON.stringify({ message: "Hello!" }), { + headers: { "Content-Type": "application/json" } + }); + }, +}); +console.log("Server listening on port 8080"); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/http-server.js', + content: serverCode + }) + }); + + // Use serve() to start, wait, and expose + const serveResponse = await fetch(`${workerUrl}/api/serve`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/http-server.js', + port: 8080, + timeout: 30000 + }) + }); + + expect(serveResponse.status).toBe(200); + const serveData = (await serveResponse.json()) as { + url: string; + process: Process; + }; + + expect(serveData.url).toBeTruthy(); + expect(serveData.process.id).toBeTruthy(); + expect(serveData.process.status).toBe('running'); + + // Make a request to the exposed URL + const apiResponse = await fetch(serveData.url); + expect(apiResponse.status).toBe(200); + const apiData = (await apiResponse.json()) as { message: string }; + expect(apiData.message).toBe('Hello!'); + + // Cleanup + await fetch(`${workerUrl}/api/exposed-ports/8080`, { + method: 'DELETE' + }); + await fetch(`${workerUrl}/api/process/${serveData.process.id}`, { + method: 'DELETE', + headers + }); + }, + 90000 + ); + + test.skipIf(skipPortExposureTests)( + 'should serve with custom ready pattern', + async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a server with a custom ready message + const serverCode = ` +console.log("Connecting to database..."); +await Bun.sleep(500); +console.log("Database connected successfully!"); +const server = Bun.serve({ + port: 8080, + fetch(req) { + return new Response("OK"); + }, +}); +console.log("Server running"); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/custom-ready.js', + content: serverCode + }) + }); + + // Use serve() with custom ready pattern + const serveResponse = await fetch(`${workerUrl}/api/serve`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/custom-ready.js', + port: 8080, + ready: 'Database connected successfully!', + timeout: 30000 + }) + }); + + expect(serveResponse.status).toBe(200); + const serveData = (await serveResponse.json()) as { + url: string; + process: Process; + }; + + expect(serveData.url).toBeTruthy(); + + // Cleanup + await fetch(`${workerUrl}/api/exposed-ports/8080`, { + method: 'DELETE' + }); + await fetch(`${workerUrl}/api/process/${serveData.process.id}`, { + method: 'DELETE', + headers + }); + }, + 90000 + ); + + test('should detect pattern in stderr as well as stdout', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a script that outputs to stderr + const scriptCode = ` +console.error("Starting up in stderr..."); +await Bun.sleep(300); +console.error("Ready (stderr)"); +await Bun.sleep(60000); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/stderr.js', + content: scriptCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/stderr.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for the pattern (which appears in stderr) + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitFor`, + { + method: 'POST', + headers, + body: JSON.stringify({ + condition: 'Ready (stderr)', + timeout: 10000 + }) + } + ); + + expect(waitResponse.status).toBe(200); + const waitData = (await waitResponse.json()) as WaitForResult; + expect(waitData.line).toContain('Ready (stderr)'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); + }); +}); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index 6110a0f0..4c15a655 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -503,13 +503,74 @@ console.log('Terminal server on port ' + port); // Process start if (url.pathname === '/api/process/start' && request.method === 'POST') { const process = await executor.startProcess(body.command, { - processId: body.processId + processId: body.processId, + ready: body.ready, + readyTimeout: body.readyTimeout }); return new Response(JSON.stringify(process), { headers: { 'Content-Type': 'application/json' } }); } + // Process waitFor - waits for a condition to be met + if ( + url.pathname.startsWith('/api/process/') && + url.pathname.endsWith('/waitFor') && + request.method === 'POST' + ) { + const pathParts = url.pathname.split('/'); + const processId = pathParts[3]; + const process = await executor.getProcess(processId); + if (!process) { + return new Response(JSON.stringify({ error: 'Process not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + // condition can be string, regex pattern (as string starting with /), or number (port) + let condition = body.condition; + if ( + typeof condition === 'string' && + condition.startsWith('/') && + condition.endsWith('/') + ) { + // Convert regex string to RegExp + condition = new RegExp(condition.slice(1, -1)); + } + const result = await process.waitFor(condition, body.timeout); + return new Response(JSON.stringify(result), { + headers: { 'Content-Type': 'application/json' } + }); + } + + // Serve - starts a process, waits for readiness, and exposes port + if (url.pathname === '/api/serve' && request.method === 'POST') { + if (sessionId) { + return new Response( + JSON.stringify({ + error: + 'Serve not supported for explicit sessions. Use default sandbox.' + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); + } + const hostname = url.hostname + (url.port ? `:${url.port}` : ''); + const result = await sandbox.serve(body.command, { + port: body.port, + hostname: hostname, + ready: body.ready, + timeout: body.timeout, + env: body.env, + cwd: body.cwd + }); + return new Response(JSON.stringify(result), { + headers: { 'Content-Type': 'application/json' } + }); + } + // Process list if (url.pathname === '/api/process/list' && request.method === 'GET') { const processes = await executor.listProcesses(); From b45627e3b337f2e0bb9e1f88c1fc7f0a35eb7c72 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 3 Dec 2025 21:07:03 +0000 Subject: [PATCH 02/12] minor fix --- packages/sandbox/tests/process-readiness.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/sandbox/tests/process-readiness.test.ts b/packages/sandbox/tests/process-readiness.test.ts index bb0e54fc..6a775299 100644 --- a/packages/sandbox/tests/process-readiness.test.ts +++ b/packages/sandbox/tests/process-readiness.test.ts @@ -597,8 +597,11 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} hostname: 'example.com' }); - expect(result.process.id).toBe('proc-server'); - expect(result.url).toContain('example.com'); + // When hostname is provided, serve() returns { url, process } + expect(typeof result).toBe('object'); + const serveResult = result as { url: string; process: { id: string } }; + expect(serveResult.process.id).toBe('proc-server'); + expect(serveResult.url).toContain('example.com'); expect(sandbox.client.processes.startProcess).toHaveBeenCalled(); expect(sandbox.client.ports.exposePort).toHaveBeenCalled(); }); @@ -649,7 +652,10 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} ready: 'Custom ready message' }); - expect(result.process.id).toBe('proc-server'); + // When hostname is provided, serve() returns { url, process } + expect(typeof result).toBe('object'); + const serveResult = result as { url: string; process: { id: string } }; + expect(serveResult.process.id).toBe('proc-server'); expect(sandbox.client.processes.getProcessLogs).toHaveBeenCalled(); }); From 3c98c26d1f3aaaddec575b51da088e950fcf4733 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 3 Dec 2025 21:14:46 +0000 Subject: [PATCH 03/12] claude had things to say --- packages/sandbox/src/sandbox.ts | 81 ++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 534f6cb7..c04e61ed 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -1412,35 +1412,53 @@ export class Sandbox extends Container implements ISandbox { ): Promise { const startTime = Date.now(); const conditionStr = `port ${port}`; - const pollInterval = 500; // Check every 500ms + const targetInterval = 500; // Target interval between checks + const execTimeout = 1000; // Timeout for each nc check + let checkCount = 0; - while (Date.now() - startTime < timeout) { - // Check if process is still running - const processInfo = await this.getProcess(processId); - if ( - !processInfo || - processInfo.status === 'completed' || - processInfo.status === 'failed' || - processInfo.status === 'killed' - ) { - const logs = await this.getProcessLogs(processId).catch(() => ({ - stdout: '', - stderr: '' - })); - throw this.createExitedBeforeReadyError( - processId, - command, - conditionStr, - processInfo?.exitCode ?? 1, - logs.stdout, - logs.stderr - ); + while (true) { + const elapsed = Date.now() - startTime; + const remaining = timeout - elapsed; + + // Exit if we've exceeded timeout + if (remaining <= 0) { + break; + } + + // Skip check if remaining time is less than exec timeout + if (remaining < execTimeout) { + break; + } + + // Check process status less frequently (every 3rd iteration) to reduce latency + if (checkCount % 3 === 0) { + const processInfo = await this.getProcess(processId); + if ( + !processInfo || + processInfo.status === 'completed' || + processInfo.status === 'failed' || + processInfo.status === 'killed' + ) { + const logs = await this.getProcessLogs(processId).catch(() => ({ + stdout: '', + stderr: '' + })); + throw this.createExitedBeforeReadyError( + processId, + command, + conditionStr, + processInfo?.exitCode ?? 1, + logs.stdout, + logs.stderr + ); + } } // Try to connect to the port using nc + const checkStart = Date.now(); try { const result = await this.exec(`nc -z localhost ${port}`, { - timeout: 1000 + timeout: Math.min(execTimeout, remaining) }); if (result.exitCode === 0) { return {}; // Port is available @@ -1449,8 +1467,16 @@ export class Sandbox extends Container implements ISandbox { // Port not ready yet, continue polling } - // Wait before next check - await new Promise((resolve) => setTimeout(resolve, pollInterval)); + checkCount++; + + // Calculate sleep time accounting for exec duration + const checkDuration = Date.now() - checkStart; + const sleepTime = Math.max(0, targetInterval - checkDuration); + + // Only sleep if we have time remaining + if (sleepTime > 0 && Date.now() - startTime + sleepTime < timeout) { + await new Promise((resolve) => setTimeout(resolve, sleepTime)); + } } // Timeout @@ -1494,8 +1520,9 @@ export class Sandbox extends Container implements ISandbox { // Find the full line containing the match const lines = text.split('\n'); for (const line of lines) { - if (pattern.test(line)) { - return { line, match: line.match(pattern) || undefined }; + const lineMatch = line.match(pattern); + if (lineMatch) { + return { line, match: lineMatch }; } } return { line: match[0], match }; From d2cc7cea33bfd02be361327ccea02db76565161f Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 3 Dec 2025 21:21:35 +0000 Subject: [PATCH 04/12] always wait for port --- packages/sandbox/src/sandbox.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index c04e61ed..d41d3293 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -1688,22 +1688,16 @@ export class Sandbox extends Container implements ISandbox { const { port, hostname, ready, timeout = 60_000, env, cwd } = options; - // Start the process with port-based readiness by default - // If a ready pattern is also provided, we'll check both + // Start the process, optionally waiting for a log pattern first const processOptions: ProcessOptions = { - ready: ready ?? port, // Default to port check if no pattern specified + ready, // Only pattern - port check happens below readyTimeout: timeout, env, cwd }; const proc = await this.startProcess(command, processOptions); - - // If both ready pattern AND port were specified, also wait for port - if (ready !== undefined && typeof ready !== 'number') { - // Pattern was specified, now also check port - await proc.waitFor(port, timeout); - } + await proc.waitFor(port, timeout); // If hostname is provided, expose the port and return full object if (hostname) { From fe2aae4822ba37bee0ca626d44db023ce901bc34 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 3 Dec 2025 21:56:18 +0000 Subject: [PATCH 05/12] fix for the tests --- packages/sandbox/src/sandbox.ts | 112 +++++++++++++++++--------------- 1 file changed, 58 insertions(+), 54 deletions(-) diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index d41d3293..30a42d02 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -1320,63 +1320,73 @@ export class Sandbox extends Container implements ISandbox { }); } - // Stream new logs and check for pattern + // Stream new logs and check for pattern with timeout const stream = await this.streamProcessLogs(processId); - try { - for await (const event of parseSSEStream(stream)) { - // Check timeout - if (Date.now() - startTime > timeout) { - throw this.createReadyTimeoutError( + // Create a timeout promise that rejects after remaining time + const remainingTime = timeout - (Date.now() - startTime); + if (remainingTime <= 0) { + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout, + collectedStdout, + collectedStderr + ); + } + + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + this.createReadyTimeoutError( processId, command, conditionStr, timeout, collectedStdout, collectedStderr - ); - } + ) + ); + }, remainingTime); + }); - // Handle different event types - if (event.type === 'stdout' || event.type === 'stderr') { - const data = event.data || ''; + try { + // Process stream with timeout + const streamProcessor = async (): Promise => { + for await (const event of parseSSEStream(stream)) { + // Handle different event types + if (event.type === 'stdout' || event.type === 'stderr') { + const data = event.data || ''; + + if (event.type === 'stdout') { + collectedStdout += data; + } else { + collectedStderr += data; + } - if (event.type === 'stdout') { - collectedStdout += data; - } else { - collectedStderr += data; + // Check for pattern match + const result = this.matchPattern(data, pattern); + if (result) { + return result; + } } - // Check for pattern match - const result = this.matchPattern(data, pattern); - if (result) { - return result; + // Process exited + if (event.type === 'exit') { + throw this.createExitedBeforeReadyError( + processId, + command, + conditionStr, + event.exitCode ?? 1, + collectedStdout, + collectedStderr + ); } } - // Process exited - if (event.type === 'exit') { - throw this.createExitedBeforeReadyError( - processId, - command, - conditionStr, - event.exitCode ?? 1, - collectedStdout, - collectedStderr - ); - } - } - } catch (error) { - // Re-throw our custom errors - if ( - error instanceof ProcessReadyTimeoutError || - error instanceof ProcessExitedBeforeReadyError - ) { - throw error; - } - - // Check if it's a timeout - if (Date.now() - startTime > timeout) { + // Stream ended without finding pattern throw this.createReadyTimeoutError( processId, command, @@ -1385,20 +1395,14 @@ export class Sandbox extends Container implements ISandbox { collectedStdout, collectedStderr ); - } + }; - throw error; + return await Promise.race([streamProcessor(), timeoutPromise]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } } - - // Stream ended without finding pattern - throw this.createReadyTimeoutError( - processId, - command, - conditionStr, - timeout, - collectedStdout, - collectedStderr - ); } /** From 47342c7f528e84544e656b0a6abff95c0985d942 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 3 Dec 2025 23:15:52 +0000 Subject: [PATCH 06/12] use tcp instead --- packages/sandbox/src/sandbox.ts | 35 +++++++++----- .../sandbox/tests/process-readiness.test.ts | 47 ++++++++++++++++++- tests/e2e/process-readiness-workflow.test.ts | 5 +- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 30a42d02..0bfc1ad7 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -1269,7 +1269,6 @@ export class Sandbox extends Container implements ISandbox { condition: ReadyCondition, timeout: number = 30_000 ): Promise { - const startTime = Date.now(); const conditionStr = this.conditionToString(condition); // For port-based waiting @@ -1298,8 +1297,15 @@ export class Sandbox extends Container implements ISandbox { // First check existing logs try { const existingLogs = await this.getProcessLogs(processId); + // Ensure existing logs end with newline for proper line separation from streamed output collectedStdout = existingLogs.stdout; + if (collectedStdout && !collectedStdout.endsWith('\n')) { + collectedStdout += '\n'; + } collectedStderr = existingLogs.stderr; + if (collectedStderr && !collectedStderr.endsWith('\n')) { + collectedStderr += '\n'; + } // Check stdout const stdoutResult = this.matchPattern(existingLogs.stdout, pattern); @@ -1362,14 +1368,18 @@ export class Sandbox extends Container implements ISandbox { if (event.type === 'stdout') { collectedStdout += data; + // Check accumulated buffer for pattern (handles patterns split across chunks) + const result = this.matchPattern(collectedStdout, pattern); + if (result) { + return result; + } } else { collectedStderr += data; - } - - // Check for pattern match - const result = this.matchPattern(data, pattern); - if (result) { - return result; + // Check accumulated buffer for pattern (handles patterns split across chunks) + const result = this.matchPattern(collectedStderr, pattern); + if (result) { + return result; + } } } @@ -1458,12 +1468,15 @@ export class Sandbox extends Container implements ISandbox { } } - // Try to connect to the port using nc + // Try to connect to the port using bash's /dev/tcp const checkStart = Date.now(); try { - const result = await this.exec(`nc -z localhost ${port}`, { - timeout: Math.min(execTimeout, remaining) - }); + const result = await this.exec( + `bash -c 'echo > /dev/tcp/localhost/${port}' 2>/dev/null`, + { + timeout: Math.min(execTimeout, remaining) + } + ); if (result.exitCode === 0) { return {}; // Port is available } diff --git a/packages/sandbox/tests/process-readiness.test.ts b/packages/sandbox/tests/process-readiness.test.ts index 6a775299..703d9c45 100644 --- a/packages/sandbox/tests/process-readiness.test.ts +++ b/packages/sandbox/tests/process-readiness.test.ts @@ -180,6 +180,49 @@ describe('Process Readiness Feature', () => { expect(result.line).toBe('Server ready on port 3000'); }); + + it('should find pattern that spans multiple SSE chunks', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + // Mock empty historical logs + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: '', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + // Simulate pattern split across multiple SSE chunks: + // "Server listen" in chunk 1, "ing on port 3000" in chunk 2 + const sseChunk1 = `data: {"type":"stdout","data":"Server listen","timestamp":"${new Date().toISOString()}"}\n\n`; + const sseChunk2 = `data: {"type":"stdout","data":"ing on port 3000\\n","timestamp":"${new Date().toISOString()}"}\n\n`; + + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sseChunk1)); + controller.enqueue(new TextEncoder().encode(sseChunk2)); + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + const result = await proc.waitFor('Server listening on port 3000'); + + // Should find the pattern even though it was split across chunks + expect(result.line).toBe('Server listening on port 3000'); + }); }); describe('regex pattern matching', () => { @@ -248,7 +291,7 @@ describe('Process Readiness Feature', () => { stdout: '', stderr: '', exitCode: 0, - command: 'nc -z localhost 3000', + command: "bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null", timestamp: new Date().toISOString() } as any); @@ -257,7 +300,7 @@ describe('Process Readiness Feature', () => { expect(result).toEqual({}); expect(sandbox.client.commands.execute).toHaveBeenCalledWith( - 'nc -z localhost 3000', + "bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null", expect.any(String), expect.objectContaining({ timeoutMs: 1000 }) ); diff --git a/tests/e2e/process-readiness-workflow.test.ts b/tests/e2e/process-readiness-workflow.test.ts index 47085462..e0664ea1 100644 --- a/tests/e2e/process-readiness-workflow.test.ts +++ b/tests/e2e/process-readiness-workflow.test.ts @@ -110,12 +110,15 @@ await Bun.sleep(60000); // Keep running // Write a Bun server that listens on a port const serverCode = ` const server = Bun.serve({ + hostname: "0.0.0.0", port: 9090, fetch(req) { return new Response("OK"); }, }); -console.log("Server started"); +console.log("Server started on " + server.hostname + ":" + server.port); +// Keep process alive +await Bun.sleep(60000); `.trim(); await fetch(`${workerUrl}/api/file/write`, { From a870ad51c917850714097f007ad886c46f01042d Mon Sep 17 00:00:00 2001 From: katereznykova Date: Thu, 4 Dec 2025 16:35:52 +0000 Subject: [PATCH 07/12] got rid of a few patterns, added a few new patterns --- packages/sandbox/README.md | 50 +-- packages/sandbox/src/index.ts | 6 +- packages/sandbox/src/sandbox.ts | 125 ++----- .../sandbox/tests/process-readiness.test.ts | 311 ++-------------- packages/shared/src/index.ts | 7 +- packages/shared/src/types.ts | 90 +---- tests/e2e/process-readiness-workflow.test.ts | 350 +++++++++--------- tests/e2e/test-worker/index.ts | 61 ++- 8 files changed, 319 insertions(+), 681 deletions(-) diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index d98b8f53..1037b958 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -105,12 +105,13 @@ export default { ` ); - const { url: previewUrl, process } = await sandbox.serve( - 'bun run /workspace/server.js', - { port: 8080, hostname: url.hostname } - ); + const proc = await sandbox.startProcess('bun run /workspace/server.js'); + await proc.waitForPort(8080); + const { url: previewUrl } = await sandbox.exposePort(8080, { + hostname: url.hostname + }); - return Response.json({ previewUrl, processId: process.id }); + return Response.json({ previewUrl, processId: proc.id }); } return new Response('Try /run, /file, or /server'); @@ -120,32 +121,31 @@ export default { ## Process Readiness -Wait for processes to be ready before proceeding. Three patterns available: +Wait for processes to be ready before proceeding: ```typescript -// Pattern 1: Inline readiness - blocks until pattern appears (recommended) -const proc = await sandbox.startProcess('npm start', { - ready: 'Server listening on port 3000', - readyTimeout: 30000 -}); - -// Pattern 2: Sequential waits - for multiple conditions const proc = await sandbox.startProcess('npm start'); -await proc.waitFor('Database connected'); -await proc.waitFor(3000); // Wait for port 3000 to be available - -// Pattern 3: Server shorthand - start, wait, and expose in one call -const { url, process } = await sandbox.serve('npm start', { - port: 3000, - hostname: 'example.com' -}); + +// Wait for a log message +await proc.waitForLog('Database connected'); + +// Wait for a regex pattern (returns match info) +const result = await proc.waitForLog(/listening on port (\d+)/); +console.log(result.match[1]); // Access captured groups + +// Wait for a port to be available +await proc.waitForPort(3000); + +// Chain multiple conditions +await proc.waitForLog('DB ready'); +await setupConnectionPool(); +await proc.waitForPort(3000); ``` -Conditions can be: +Methods: -- **String** - Waits for substring in stdout/stderr -- **RegExp** - Waits for pattern match (returns capture groups) -- **Number** - Waits for port to be available +- **`waitForLog(pattern, timeout?)`** - Waits for string or RegExp in stdout/stderr +- **`waitForPort(port, timeout?)`** - Waits for port to accept connections ## Documentation diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 8617e188..2a077f39 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -37,14 +37,12 @@ export type { Process, ProcessOptions, ProcessStatus, - // Process readiness types - ReadyCondition, RunCodeOptions, SandboxOptions, - ServeOptions, SessionOptions, StreamOptions, - WaitForResult + // Process readiness types + WaitForLogResult } from '@repo/shared'; // Export type guards for runtime validation export { isExecResult, isProcess, isProcessStatus } from '@repo/shared'; diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 0bfc1ad7..af49a618 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -15,13 +15,11 @@ import type { Process, ProcessOptions, ProcessStatus, - ReadyCondition, RunCodeOptions, SandboxOptions, - ServeOptions, SessionOptions, StreamOptions, - WaitForResult + WaitForLogResult } from '@repo/shared'; import { createLogger, @@ -1250,45 +1248,33 @@ export class Sandbox extends Container implements ISandbox { return { stdout: logs.stdout, stderr: logs.stderr }; }, - waitFor: async ( - condition: ReadyCondition, + waitForLog: async ( + pattern: string | RegExp, timeout?: number - ): Promise => { - return this.waitForCondition(data.id, data.command, condition, timeout); + ): Promise => { + return this.waitForLogPattern(data.id, data.command, pattern, timeout); + }, + + waitForPort: async (port: number, timeout?: number): Promise => { + await this.waitForPortReady( + data.id, + data.command, + port, + timeout ?? 30_000 + ); } }; } - /** - * Wait for a condition to be met for a process - * Supports log patterns (string/regex) and port availability - */ - private async waitForCondition( - processId: string, - command: string, - condition: ReadyCondition, - timeout: number = 30_000 - ): Promise { - const conditionStr = this.conditionToString(condition); - - // For port-based waiting - if (typeof condition === 'number') { - return this.waitForPortReady(processId, command, condition, timeout); - } - - // For log-based waiting (string or regex) - return this.waitForLog(processId, command, condition, timeout); - } - /** * Wait for a log pattern to appear in process output */ - private async waitForLog( + private async waitForLogPattern( processId: string, command: string, pattern: string | RegExp, - timeout: number - ): Promise { + timeout: number = 30_000 + ): Promise { const startTime = Date.now(); const conditionStr = this.conditionToString(pattern); let collectedStdout = ''; @@ -1360,7 +1346,7 @@ export class Sandbox extends Container implements ISandbox { try { // Process stream with timeout - const streamProcessor = async (): Promise => { + const streamProcessor = async (): Promise => { for await (const event of parseSSEStream(stream)) { // Handle different event types if (event.type === 'stdout' || event.type === 'stderr') { @@ -1423,7 +1409,7 @@ export class Sandbox extends Container implements ISandbox { command: string, port: number, timeout: number - ): Promise { + ): Promise { const startTime = Date.now(); const conditionStr = `port ${port}`; const targetInterval = 500; // Target interval between checks @@ -1478,7 +1464,7 @@ export class Sandbox extends Container implements ISandbox { } ); if (result.exitCode === 0) { - return {}; // Port is available + return; // Port is available } } catch { // Port not ready yet, continue polling @@ -1517,7 +1503,7 @@ export class Sandbox extends Container implements ISandbox { private matchPattern( text: string, pattern: string | RegExp - ): WaitForResult | null { + ): WaitForLogResult | null { if (typeof pattern === 'string') { // Simple substring match if (text.includes(pattern)) { @@ -1549,16 +1535,13 @@ export class Sandbox extends Container implements ISandbox { } /** - * Convert a condition to a human-readable string + * Convert a log pattern to a human-readable string */ - private conditionToString(condition: ReadyCondition): string { - if (typeof condition === 'string') { - return `"${condition}"`; - } else if (typeof condition === 'number') { - return `port ${condition}`; - } else { - return condition.toString(); + private conditionToString(pattern: string | RegExp): string { + if (typeof pattern === 'string') { + return `"${pattern}"`; } + return pattern.toString(); } /** @@ -1585,7 +1568,7 @@ export class Sandbox extends Container implements ISandbox { }, httpStatus: 408, timestamp: new Date().toISOString(), - suggestion: `Check if your process outputs ${condition}. You can increase the timeout with { readyTimeout: ${timeout * 2} }` + suggestion: `Check if your process outputs ${condition}. You can increase the timeout parameter.` }); } @@ -1663,12 +1646,6 @@ export class Sandbox extends Container implements ISandbox { options.onStart(processObj); } - // If ready condition is specified, wait for it before returning - if (options?.ready !== undefined) { - const readyTimeout = options.readyTimeout ?? 30_000; - await processObj.waitFor(options.ready, readyTimeout); - } - return processObj; } catch (error) { if (options?.onError && error instanceof Error) { @@ -1679,54 +1656,6 @@ export class Sandbox extends Container implements ISandbox { } } - /** - * Start a server process and wait for it to be ready - * Returns the preview URL directly for simple cases - * - * @example - * // Simple usage - get URL directly - * const url = await sandbox.serve("npm run dev", 3000); - * - * // With options - get full service object - * const { url, process } = await sandbox.serve("npm run dev", { - * port: 3000, - * hostname: "app.example.com", - * ready: /listening/ - * }); - */ - async serve( - command: string, - portOrOptions: number | ServeOptions - ): Promise { - const options: ServeOptions = - typeof portOrOptions === 'number' - ? { port: portOrOptions } - : portOrOptions; - - const { port, hostname, ready, timeout = 60_000, env, cwd } = options; - - // Start the process, optionally waiting for a log pattern first - const processOptions: ProcessOptions = { - ready, // Only pattern - port check happens below - readyTimeout: timeout, - env, - cwd - }; - - const proc = await this.startProcess(command, processOptions); - await proc.waitFor(port, timeout); - - // If hostname is provided, expose the port and return full object - if (hostname) { - const { url } = await this.exposePort(port, { hostname }); - return { url, process: proc }; - } - - // No hostname - just return a placeholder URL indicating port is ready - // The user would need to call exposePort separately for a real URL - return `http://localhost:${port}`; - } - async listProcesses(sessionId?: string): Promise { const session = sessionId ?? (await this.ensureDefaultSession()); const response = await this.client.processes.listProcesses(); diff --git a/packages/sandbox/tests/process-readiness.test.ts b/packages/sandbox/tests/process-readiness.test.ts index 703d9c45..9dda0b45 100644 --- a/packages/sandbox/tests/process-readiness.test.ts +++ b/packages/sandbox/tests/process-readiness.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for process readiness feature * - * Tests the waitFor(), serve(), and startProcess({ ready }) functionality + * Tests the waitForLog() and waitForPort() functionality */ import type { DurableObjectState } from '@cloudflare/workers-types'; @@ -91,7 +91,7 @@ describe('Process Readiness Feature', () => { vi.restoreAllMocks(); }); - describe('waitFor() method', () => { + describe('waitForLog() method', () => { describe('string pattern matching', () => { it('should resolve when string pattern found in existing logs', async () => { vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ @@ -124,7 +124,7 @@ describe('Process Readiness Feature', () => { } as any); const proc = await sandbox.startProcess('npm start'); - const result = await proc.waitFor('Server listening on port 3000'); + const result = await proc.waitForLog('Server listening on port 3000'); expect(result.line).toContain('Server listening on port 3000'); }); @@ -176,7 +176,7 @@ describe('Process Readiness Feature', () => { ).mockResolvedValue(mockStream); const proc = await sandbox.startProcess('npm start'); - const result = await proc.waitFor('Server ready on port 3000'); + const result = await proc.waitForLog('Server ready on port 3000'); expect(result.line).toBe('Server ready on port 3000'); }); @@ -218,7 +218,7 @@ describe('Process Readiness Feature', () => { ).mockResolvedValue(mockStream); const proc = await sandbox.startProcess('npm start'); - const result = await proc.waitFor('Server listening on port 3000'); + const result = await proc.waitForLog('Server listening on port 3000'); // Should find the pattern even though it was split across chunks expect(result.line).toBe('Server listening on port 3000'); @@ -256,7 +256,7 @@ describe('Process Readiness Feature', () => { } as any); const proc = await sandbox.startProcess('npm start'); - const result = await proc.waitFor(/port (\d+)/); + const result = await proc.waitForLog(/port (\d+)/); expect(result.match).toBeDefined(); expect(result.match![0]).toBe('port 8080'); @@ -264,49 +264,6 @@ describe('Process Readiness Feature', () => { }); }); - describe('port readiness', () => { - it('should wait for port to become available', async () => { - vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ - success: true, - processId: 'proc-server', - pid: 12345, - command: 'npm start', - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ - success: true, - process: { - id: 'proc-server', - pid: 12345, - command: 'npm start', - status: 'running', - startTime: new Date().toISOString() - }, - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({ - success: true, - stdout: '', - stderr: '', - exitCode: 0, - command: "bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null", - timestamp: new Date().toISOString() - } as any); - - const proc = await sandbox.startProcess('npm start'); - const result = await proc.waitFor(3000); - - expect(result).toEqual({}); - expect(sandbox.client.commands.execute).toHaveBeenCalledWith( - "bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null", - expect.any(String), - expect.objectContaining({ timeoutMs: 1000 }) - ); - }); - }); - describe('timeout handling', () => { it('should throw ProcessReadyTimeoutError when pattern not found', async () => { vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ @@ -351,7 +308,7 @@ describe('Process Readiness Feature', () => { const proc = await sandbox.startProcess('npm start'); - await expect(proc.waitFor('never-appears', 100)).rejects.toThrow( + await expect(proc.waitForLog('never-appears', 100)).rejects.toThrow( ProcessReadyTimeoutError ); }); @@ -399,7 +356,7 @@ describe('Process Readiness Feature', () => { const proc = await sandbox.startProcess('npm start'); try { - await proc.waitFor('never-found', 100); + await proc.waitForLog('never-found', 100); expect.fail('Should have thrown'); } catch (error) { expect(error).toBeInstanceOf(ProcessReadyTimeoutError); @@ -450,7 +407,7 @@ data: {"type":"exit","exitCode":1,"timestamp":"${new Date().toISOString()}"} const proc = await sandbox.startProcess('npm start'); - await expect(proc.waitFor('Server ready')).rejects.toThrow( + await expect(proc.waitForLog('Server ready')).rejects.toThrow( ProcessExitedBeforeReadyError ); }); @@ -493,7 +450,7 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} const proc = await sandbox.startProcess('npm start'); try { - await proc.waitFor('Server ready'); + await proc.waitForLog('Server ready'); expect.fail('Should have thrown'); } catch (error) { expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); @@ -506,105 +463,8 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} }); }); - describe('startProcess() with ready option', () => { - it('should wait for ready pattern before returning', async () => { - vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ - success: true, - processId: 'proc-server', - pid: 12345, - command: 'npm start', - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ - success: true, - process: { - id: 'proc-server', - pid: 12345, - command: 'npm start', - status: 'running', - startTime: new Date().toISOString() - }, - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ - success: true, - processId: 'proc-server', - stdout: 'Server ready', - stderr: '', - timestamp: new Date().toISOString() - } as any); - - const proc = await sandbox.startProcess('npm start', { - ready: 'Server ready' - }); - - expect(proc.id).toBe('proc-server'); - expect(sandbox.client.processes.getProcessLogs).toHaveBeenCalled(); - }); - - it('should respect readyTimeout option', async () => { - vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ - success: true, - processId: 'proc-server', - pid: 12345, - command: 'npm start', - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ - success: true, - process: { - id: 'proc-server', - pid: 12345, - command: 'npm start', - status: 'running', - startTime: new Date().toISOString() - }, - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ - success: true, - processId: 'proc-server', - stdout: 'Starting...', - stderr: '', - timestamp: new Date().toISOString() - } as any); - - const mockStream = new ReadableStream({ - start(controller) { - controller.close(); - } - }); - - vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue( - mockStream - ); - - await expect( - sandbox.startProcess('npm start', { - ready: 'never-appears', - readyTimeout: 50 - }) - ).rejects.toThrow(ProcessReadyTimeoutError); - }); - }); - - describe('serve() method', () => { - beforeEach(async () => { - await sandbox.setSandboxName('test-sandbox'); - - vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({ - success: true, - port: 8080, - url: '', - timestamp: new Date().toISOString() - } as any); - }); - - it('should start process, wait for port, and expose it', async () => { + describe('waitForPort() method', () => { + it('should wait for port to become available', async () => { vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ success: true, processId: 'proc-server', @@ -625,31 +485,26 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} timestamp: new Date().toISOString() } as any); - // Port check succeeds vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({ success: true, stdout: '', stderr: '', exitCode: 0, - command: 'nc -z localhost 8080', + command: "bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null", timestamp: new Date().toISOString() } as any); - const result = await sandbox.serve('npm start', { - port: 8080, - hostname: 'example.com' - }); + const proc = await sandbox.startProcess('npm start'); + await proc.waitForPort(3000); - // When hostname is provided, serve() returns { url, process } - expect(typeof result).toBe('object'); - const serveResult = result as { url: string; process: { id: string } }; - expect(serveResult.process.id).toBe('proc-server'); - expect(serveResult.url).toContain('example.com'); - expect(sandbox.client.processes.startProcess).toHaveBeenCalled(); - expect(sandbox.client.ports.exposePort).toHaveBeenCalled(); + expect(sandbox.client.commands.execute).toHaveBeenCalledWith( + "bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null", + expect.any(String), + expect.objectContaining({ timeoutMs: 1000 }) + ); }); - it('should use custom ready pattern when provided', async () => { + it('should throw ProcessExitedBeforeReadyError when process exits before port is ready', async () => { vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ success: true, processId: 'proc-server', @@ -664,87 +519,31 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} id: 'proc-server', pid: 12345, command: 'npm start', - status: 'running', + status: 'completed', + exitCode: 1, startTime: new Date().toISOString() }, timestamp: new Date().toISOString() } as any); - // Pattern found in logs immediately vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ success: true, processId: 'proc-server', - stdout: 'Custom ready message', - stderr: '', - timestamp: new Date().toISOString() - } as any); - - // Port check also needed since serve() checks both pattern AND port when ready is provided - vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({ - success: true, - stdout: '', - stderr: '', - exitCode: 0, - command: 'nc -z localhost 8080', - timestamp: new Date().toISOString() - } as any); - - const result = await sandbox.serve('npm start', { - port: 8080, - hostname: 'example.com', - ready: 'Custom ready message' - }); - - // When hostname is provided, serve() returns { url, process } - expect(typeof result).toBe('object'); - const serveResult = result as { url: string; process: { id: string } }; - expect(serveResult.process.id).toBe('proc-server'); - expect(sandbox.client.processes.getProcessLogs).toHaveBeenCalled(); - }); - - it('should pass environment variables to process', async () => { - vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ - success: true, - processId: 'proc-server', - pid: 12345, - command: 'npm start', - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ - success: true, - process: { - id: 'proc-server', - pid: 12345, - command: 'npm start', - status: 'running', - startTime: new Date().toISOString() - }, - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({ - success: true, stdout: '', stderr: '', - exitCode: 0, - command: 'nc -z localhost 8080', timestamp: new Date().toISOString() } as any); - await sandbox.serve('npm start', { - port: 8080, - hostname: 'example.com', - env: { NODE_ENV: 'production' } - }); + const proc = await sandbox.startProcess('npm start'); - expect(sandbox.client.processes.startProcess).toHaveBeenCalledWith( - 'npm start', - expect.any(String), - expect.objectContaining({ - env: { NODE_ENV: 'production' } - }) - ); + try { + await proc.waitForPort(3000); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); + const exitError = error as ProcessExitedBeforeReadyError; + expect(exitError.condition).toBe('port 3000'); + } }); }); @@ -791,7 +590,7 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} const proc = await sandbox.startProcess('npm start'); try { - await proc.waitFor('Server ready', 50); + await proc.waitForLog('Server ready', 50); expect.fail('Should have thrown'); } catch (error) { expect(error).toBeInstanceOf(ProcessReadyTimeoutError); @@ -842,7 +641,7 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} const proc = await sandbox.startProcess('npm start'); try { - await proc.waitFor(/port \d+/, 50); + await proc.waitForLog(/port \d+/, 50); expect.fail('Should have thrown'); } catch (error) { expect(error).toBeInstanceOf(ProcessReadyTimeoutError); @@ -850,47 +649,5 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} expect(readyError.condition).toBe('/port \\d+/'); } }); - - it('should format port conditions as port numbers', async () => { - vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ - success: true, - processId: 'proc-server', - pid: 12345, - command: 'npm start', - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ - success: true, - process: { - id: 'proc-server', - pid: 12345, - command: 'npm start', - status: 'completed', - exitCode: 1, - startTime: new Date().toISOString() - }, - timestamp: new Date().toISOString() - } as any); - - vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ - success: true, - processId: 'proc-server', - stdout: '', - stderr: '', - timestamp: new Date().toISOString() - } as any); - - const proc = await sandbox.startProcess('npm start'); - - try { - await proc.waitFor(3000); - expect.fail('Should have thrown'); - } catch (error) { - expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); - const exitError = error as ProcessExitedBeforeReadyError; - expect(exitError.condition).toBe('port 3000'); - } - }); }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 296472c6..3cad5417 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -94,20 +94,17 @@ export type { ProcessStartResult, ProcessStatus, ReadFileResult, - // Process readiness types - ReadyCondition, RenameFileResult, // Sandbox configuration options SandboxOptions, - // Serve options - ServeOptions, // Session management result types SessionCreateResult, SessionDeleteResult, SessionOptions, ShutdownResult, StreamOptions, - WaitForResult, + // Process readiness types + WaitForLogResult, WriteFileResult } from './types.js'; export { isExecResult, isProcess, isProcessStatus } from './types.js'; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index f086a163..3fe82de2 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -101,49 +101,15 @@ export interface ExecResult { } /** - * Condition to wait for before considering a process "ready" - * - string: Wait for this substring in logs - * - RegExp: Wait for this pattern in logs - * - number: Wait for this port to accept connections + * Result from waiting for a log pattern */ -export type ReadyCondition = string | RegExp | number; - -/** - * Result from waiting for a condition - */ -export interface WaitForResult { - /** The log line that matched (for string/regex conditions) */ - line?: string; +export interface WaitForLogResult { + /** The log line that matched */ + line: string; /** Regex capture groups (if condition was a RegExp) */ match?: RegExpMatchArray; } -/** - * Options for the serve() method - */ -export interface ServeOptions { - /** Port to wait for and optionally expose */ - port: number; - - /** Hostname for preview URL (required for URL generation) */ - hostname?: string; - - /** - * Additional ready condition (log pattern) - * If specified, waits for BOTH the log pattern AND the port - */ - ready?: string | RegExp; - - /** Timeout in milliseconds (default: 60000) */ - timeout?: number; - - /** Environment variables for the process */ - env?: Record; - - /** Working directory for the process */ - cwd?: string; -} - // Background process types export interface ProcessOptions extends BaseExecOptions { /** @@ -176,30 +142,6 @@ export interface ProcessOptions extends BaseExecOptions { * Callback for process errors */ onError?: (error: Error) => void; - - /** - * Wait for process to be "ready" before resolving - * - string: Wait for this substring in logs - * - RegExp: Wait for this pattern in logs - * - number: Wait for this port to accept connections - * - * @example - * // Wait for log message - * await sandbox.startProcess("npm run dev", { ready: "listening" }); - * - * // Wait for regex pattern - * await sandbox.startProcess("npm run dev", { ready: /port (\d+)/ }); - * - * // Wait for port - * await sandbox.startProcess("npm run dev", { ready: 3000 }); - */ - ready?: ReadyCondition; - - /** - * Timeout for readiness check in milliseconds (default: 30000) - * Only used when `ready` option is specified - */ - readyTimeout?: number; } export type ProcessStatus = @@ -267,18 +209,26 @@ export interface Process { getLogs(): Promise<{ stdout: string; stderr: string }>; /** - * Wait for a condition to be met - * - string: Wait for this substring in logs - * - RegExp: Wait for this pattern in logs - * - number: Wait for this port to accept connections + * Wait for a log pattern to appear in process output * * @example * const proc = await sandbox.startProcess("python train.py"); - * await proc.waitFor("Epoch 1 complete"); - * await proc.waitFor(/Epoch (\d+) complete/); - * await proc.waitFor(8080); // Wait for port + * await proc.waitForLog("Epoch 1 complete"); + * await proc.waitForLog(/Epoch (\d+) complete/); + */ + waitForLog( + pattern: string | RegExp, + timeout?: number + ): Promise; + + /** + * Wait for a port to accept connections + * + * @example + * const proc = await sandbox.startProcess("npm run dev"); + * await proc.waitForPort(3000); */ - waitFor(condition: ReadyCondition, timeout?: number): Promise; + waitForPort(port: number, timeout?: number): Promise; } // Streaming event types diff --git a/tests/e2e/process-readiness-workflow.test.ts b/tests/e2e/process-readiness-workflow.test.ts index e0664ea1..aa9cff02 100644 --- a/tests/e2e/process-readiness-workflow.test.ts +++ b/tests/e2e/process-readiness-workflow.test.ts @@ -5,7 +5,7 @@ import { createTestHeaders, cleanupSandbox } from './helpers/test-fixtures'; -import type { Process, WaitForResult, PortExposeResult } from '@repo/shared'; +import type { Process, WaitForLogResult, PortExposeResult } from '@repo/shared'; // Port exposure tests require custom domain with wildcard DNS routing const skipPortExposureTests = @@ -15,10 +15,8 @@ const skipPortExposureTests = * Process Readiness Workflow Integration Tests * * Tests the process readiness feature including: - * - waitFor() method with string patterns - * - waitFor() method with port checking - * - startProcess() with ready option - * - serve() method for server processes + * - waitForLog() method with string and regex patterns + * - waitForPort() method for port checking */ describe('Process Readiness Workflow', () => { describe('local', () => { @@ -79,21 +77,21 @@ await Bun.sleep(60000); // Keep running const startData = (await startResponse.json()) as Process; const processId = startData.id; - // Wait for the pattern + // Wait for the log pattern const waitResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitFor`, + `${workerUrl}/api/process/${processId}/waitForLog`, { method: 'POST', headers, body: JSON.stringify({ - condition: 'Server ready on port 8080', + pattern: 'Server ready on port 8080', timeout: 10000 }) } ); expect(waitResponse.status).toBe(200); - const waitData = (await waitResponse.json()) as WaitForResult; + const waitData = (await waitResponse.json()) as WaitForLogResult; expect(waitData.line).toContain('Server ready on port 8080'); // Cleanup @@ -145,12 +143,12 @@ await Bun.sleep(60000); // Wait for port 9090 to be available const waitResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitFor`, + `${workerUrl}/api/process/${processId}/waitForPort`, { method: 'POST', headers, body: JSON.stringify({ - condition: 9090, + port: 9090, timeout: 15000 }) } @@ -177,16 +175,21 @@ await Bun.sleep(60000); }); }, 60000); - test('should start process with ready option and block until ready', async () => { + test('should chain waitForLog and waitForPort for multiple conditions', async () => { currentSandboxId = createSandboxId(); const headers = createTestHeaders(currentSandboxId); - // Write a script with delayed ready message + // Write a script with delayed ready message and a server const scriptCode = ` console.log("Initializing..."); -await Bun.sleep(1000); +await Bun.sleep(500); console.log("Database connected"); await Bun.sleep(500); +const server = Bun.serve({ + hostname: "0.0.0.0", + port: 9091, + fetch(req) { return new Response("Ready"); }, +}); console.log("Ready to serve requests"); await Bun.sleep(60000); `.trim(); @@ -200,30 +203,49 @@ await Bun.sleep(60000); }) }); - // Start process with ready option - should block until pattern appears - const startTime = Date.now(); + // Start the process const startResponse = await fetch(`${workerUrl}/api/process/start`, { method: 'POST', headers, body: JSON.stringify({ - command: 'bun run /workspace/app.js', - ready: 'Ready to serve requests', - readyTimeout: 10000 + command: 'bun run /workspace/app.js' }) }); - const duration = Date.now() - startTime; expect(startResponse.status).toBe(200); const startData = (await startResponse.json()) as Process; + const processId = startData.id; - // Should have waited at least 1.5 seconds for the delayed output - expect(duration).toBeGreaterThan(1000); + // Wait for log pattern first + const waitLogResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Database connected', + timeout: 10000 + }) + } + ); + expect(waitLogResponse.status).toBe(200); - // Process should be running - expect(startData.status).toBe('running'); + // Then wait for port + const waitPortResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { + method: 'POST', + headers, + body: JSON.stringify({ + port: 9091, + timeout: 10000 + }) + } + ); + expect(waitPortResponse.status).toBe(200); // Cleanup - await fetch(`${workerUrl}/api/process/${startData.id}`, { + await fetch(`${workerUrl}/api/process/${processId}`, { method: 'DELETE', headers }); @@ -249,24 +271,45 @@ await Bun.sleep(60000); }) }); - // Start process with short timeout + // Start the process const startResponse = await fetch(`${workerUrl}/api/process/start`, { method: 'POST', headers, body: JSON.stringify({ - command: 'bun run /workspace/slow.js', - ready: 'Server ready', - readyTimeout: 2000 + command: 'bun run /workspace/slow.js' }) }); + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for pattern with short timeout - should fail + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Server ready', + timeout: 2000 + }) + } + ); + // Should fail with timeout - expect(startResponse.status).toBe(500); - const errorData = (await startResponse.json()) as { error: string }; + expect(waitResponse.status).toBe(500); + const errorData = (await waitResponse.json()) as { error: string }; expect(errorData.error).toMatch(/timeout|did not become ready/i); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); }, 60000); - test('should fail with error if process exits before becoming ready', async () => { + test('should fail with error if process exits before pattern appears', async () => { currentSandboxId = createSandboxId(); const headers = createTestHeaders(currentSandboxId); @@ -275,147 +318,34 @@ await Bun.sleep(60000); method: 'POST', headers, body: JSON.stringify({ - command: 'echo "quick exit"', - ready: 'Server ready', - readyTimeout: 10000 + command: 'echo "quick exit"' }) }); - // Should fail because process exits before pattern appears - expect(startResponse.status).toBe(500); - const errorData = (await startResponse.json()) as { error: string }; - expect(errorData.error).toMatch( - /exited|exit|timeout|did not become ready/i - ); - }, 60000); - - test.skipIf(skipPortExposureTests)( - 'should serve a process and expose port automatically', - async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Write a simple HTTP server - const serverCode = ` -const server = Bun.serve({ - port: 8080, - fetch(req) { - return new Response(JSON.stringify({ message: "Hello!" }), { - headers: { "Content-Type": "application/json" } - }); - }, -}); -console.log("Server listening on port 8080"); - `.trim(); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/http-server.js', - content: serverCode - }) - }); - - // Use serve() to start, wait, and expose - const serveResponse = await fetch(`${workerUrl}/api/serve`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/http-server.js', - port: 8080, - timeout: 30000 - }) - }); - - expect(serveResponse.status).toBe(200); - const serveData = (await serveResponse.json()) as { - url: string; - process: Process; - }; - - expect(serveData.url).toBeTruthy(); - expect(serveData.process.id).toBeTruthy(); - expect(serveData.process.status).toBe('running'); - - // Make a request to the exposed URL - const apiResponse = await fetch(serveData.url); - expect(apiResponse.status).toBe(200); - const apiData = (await apiResponse.json()) as { message: string }; - expect(apiData.message).toBe('Hello!'); - - // Cleanup - await fetch(`${workerUrl}/api/exposed-ports/8080`, { - method: 'DELETE' - }); - await fetch(`${workerUrl}/api/process/${serveData.process.id}`, { - method: 'DELETE', - headers - }); - }, - 90000 - ); - - test.skipIf(skipPortExposureTests)( - 'should serve with custom ready pattern', - async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Write a server with a custom ready message - const serverCode = ` -console.log("Connecting to database..."); -await Bun.sleep(500); -console.log("Database connected successfully!"); -const server = Bun.serve({ - port: 8080, - fetch(req) { - return new Response("OK"); - }, -}); -console.log("Server running"); - `.trim(); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/custom-ready.js', - content: serverCode - }) - }); + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - // Use serve() with custom ready pattern - const serveResponse = await fetch(`${workerUrl}/api/serve`, { + // Wait for pattern - should fail because process exits + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { method: 'POST', headers, body: JSON.stringify({ - command: 'bun run /workspace/custom-ready.js', - port: 8080, - ready: 'Database connected successfully!', - timeout: 30000 + pattern: 'Server ready', + timeout: 10000 }) - }); - - expect(serveResponse.status).toBe(200); - const serveData = (await serveResponse.json()) as { - url: string; - process: Process; - }; - - expect(serveData.url).toBeTruthy(); + } + ); - // Cleanup - await fetch(`${workerUrl}/api/exposed-ports/8080`, { - method: 'DELETE' - }); - await fetch(`${workerUrl}/api/process/${serveData.process.id}`, { - method: 'DELETE', - headers - }); - }, - 90000 - ); + // Should fail because process exits before pattern appears + expect(waitResponse.status).toBe(500); + const errorData = (await waitResponse.json()) as { error: string }; + expect(errorData.error).toMatch( + /exited|exit|timeout|did not become ready/i + ); + }, 60000); test('should detect pattern in stderr as well as stdout', async () => { currentSandboxId = createSandboxId(); @@ -453,19 +383,19 @@ await Bun.sleep(60000); // Wait for the pattern (which appears in stderr) const waitResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitFor`, + `${workerUrl}/api/process/${processId}/waitForLog`, { method: 'POST', headers, body: JSON.stringify({ - condition: 'Ready (stderr)', + pattern: 'Ready (stderr)', timeout: 10000 }) } ); expect(waitResponse.status).toBe(200); - const waitData = (await waitResponse.json()) as WaitForResult; + const waitData = (await waitResponse.json()) as WaitForLogResult; expect(waitData.line).toContain('Ready (stderr)'); // Cleanup @@ -474,5 +404,91 @@ await Bun.sleep(60000); headers }); }, 60000); + + test.skipIf(skipPortExposureTests)( + 'should start server, wait for port, and expose it', + async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a simple HTTP server + const serverCode = ` +const server = Bun.serve({ + port: 8080, + fetch(req) { + return new Response(JSON.stringify({ message: "Hello!" }), { + headers: { "Content-Type": "application/json" } + }); + }, +}); +console.log("Server listening on port 8080"); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/http-server.js', + content: serverCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/http-server.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for port + const waitPortResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { + method: 'POST', + headers, + body: JSON.stringify({ + port: 8080, + timeout: 30000 + }) + } + ); + expect(waitPortResponse.status).toBe(200); + + // Expose the port + const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { + method: 'POST', + headers, + body: JSON.stringify({ + port: 8080 + }) + }); + + expect(exposeResponse.status).toBe(200); + const exposeData = (await exposeResponse.json()) as PortExposeResult; + expect(exposeData.url).toBeTruthy(); + + // Make a request to the exposed URL + const apiResponse = await fetch(exposeData.url); + expect(apiResponse.status).toBe(200); + const apiData = (await apiResponse.json()) as { message: string }; + expect(apiData.message).toBe('Hello!'); + + // Cleanup + await fetch(`${workerUrl}/api/exposed-ports/8080`, { + method: 'DELETE' + }); + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, + 90000 + ); }); }); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index 4c15a655..802cecdb 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -503,19 +503,17 @@ console.log('Terminal server on port ' + port); // Process start if (url.pathname === '/api/process/start' && request.method === 'POST') { const process = await executor.startProcess(body.command, { - processId: body.processId, - ready: body.ready, - readyTimeout: body.readyTimeout + processId: body.processId }); return new Response(JSON.stringify(process), { headers: { 'Content-Type': 'application/json' } }); } - // Process waitFor - waits for a condition to be met + // Process waitForLog - waits for a log pattern if ( url.pathname.startsWith('/api/process/') && - url.pathname.endsWith('/waitFor') && + url.pathname.endsWith('/waitForLog') && request.method === 'POST' ) { const pathParts = url.pathname.split('/'); @@ -527,46 +525,39 @@ console.log('Terminal server on port ' + port); headers: { 'Content-Type': 'application/json' } }); } - // condition can be string, regex pattern (as string starting with /), or number (port) - let condition = body.condition; + // pattern can be string or regex pattern (as string starting with /) + let pattern = body.pattern; if ( - typeof condition === 'string' && - condition.startsWith('/') && - condition.endsWith('/') + typeof pattern === 'string' && + pattern.startsWith('/') && + pattern.endsWith('/') ) { // Convert regex string to RegExp - condition = new RegExp(condition.slice(1, -1)); + pattern = new RegExp(pattern.slice(1, -1)); } - const result = await process.waitFor(condition, body.timeout); + const result = await process.waitForLog(pattern, body.timeout); return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' } }); } - // Serve - starts a process, waits for readiness, and exposes port - if (url.pathname === '/api/serve' && request.method === 'POST') { - if (sessionId) { - return new Response( - JSON.stringify({ - error: - 'Serve not supported for explicit sessions. Use default sandbox.' - }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); + // Process waitForPort - waits for a port to be available + if ( + url.pathname.startsWith('/api/process/') && + url.pathname.endsWith('/waitForPort') && + request.method === 'POST' + ) { + const pathParts = url.pathname.split('/'); + const processId = pathParts[3]; + const process = await executor.getProcess(processId); + if (!process) { + return new Response(JSON.stringify({ error: 'Process not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); } - const hostname = url.hostname + (url.port ? `:${url.port}` : ''); - const result = await sandbox.serve(body.command, { - port: body.port, - hostname: hostname, - ready: body.ready, - timeout: body.timeout, - env: body.env, - cwd: body.cwd - }); - return new Response(JSON.stringify(result), { + await process.waitForPort(body.port, body.timeout); + return new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } }); } From 0587dac26f7c5f6fe0afea33ea51e9935a8ca1e5 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Thu, 4 Dec 2025 16:57:39 +0000 Subject: [PATCH 08/12] small nits on top --- packages/sandbox/README.md | 53 +---- packages/sandbox/src/errors/classes.ts | 12 - packages/sandbox/src/sandbox.ts | 224 +++++++++--------- .../sandbox/tests/process-readiness.test.ts | 36 +-- packages/shared/src/errors/contexts.ts | 4 - 5 files changed, 138 insertions(+), 191 deletions(-) diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index 1037b958..d17e1fbc 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -92,61 +92,11 @@ export default { return Response.json({ content: file.content }); } - // Start a server and wait for it to be ready - if (url.pathname === '/server') { - await sandbox.writeFile( - '/workspace/server.js', - ` - const server = Bun.serve({ - port: 8080, - fetch() { return new Response("Hello from sandbox!"); } - }); - console.log("Server ready on port 8080"); - ` - ); - - const proc = await sandbox.startProcess('bun run /workspace/server.js'); - await proc.waitForPort(8080); - const { url: previewUrl } = await sandbox.exposePort(8080, { - hostname: url.hostname - }); - - return Response.json({ previewUrl, processId: proc.id }); - } - - return new Response('Try /run, /file, or /server'); + return new Response('Try /run or /file'); } }; ``` -## Process Readiness - -Wait for processes to be ready before proceeding: - -```typescript -const proc = await sandbox.startProcess('npm start'); - -// Wait for a log message -await proc.waitForLog('Database connected'); - -// Wait for a regex pattern (returns match info) -const result = await proc.waitForLog(/listening on port (\d+)/); -console.log(result.match[1]); // Access captured groups - -// Wait for a port to be available -await proc.waitForPort(3000); - -// Chain multiple conditions -await proc.waitForLog('DB ready'); -await setupConnectionPool(); -await proc.waitForPort(3000); -``` - -Methods: - -- **`waitForLog(pattern, timeout?)`** - Waits for string or RegExp in stdout/stderr -- **`waitForPort(port, timeout?)`** - Waits for port to accept connections - ## Documentation **📖 [Full Documentation](https://developers.cloudflare.com/sandbox/)** @@ -163,7 +113,6 @@ Methods: - **Code Interpreter** - Execute Python and JavaScript with rich outputs - **File System Access** - Read, write, and manage files - **Command Execution** - Run any command with streaming support -- **Process Readiness** - Wait for processes to be ready before proceeding - **Preview URLs** - Expose services with public URLs - **Git Integration** - Clone repositories directly diff --git a/packages/sandbox/src/errors/classes.ts b/packages/sandbox/src/errors/classes.ts index 2d8961df..caad715b 100644 --- a/packages/sandbox/src/errors/classes.ts +++ b/packages/sandbox/src/errors/classes.ts @@ -621,12 +621,6 @@ export class ProcessReadyTimeoutError extends SandboxError extends Container implements ISandbox { }, waitForPort: async (port: number, timeout?: number): Promise => { - await this.waitForPortReady( - data.id, - data.command, - port, - timeout ?? 30_000 - ); + await this.waitForPortReady(data.id, data.command, port, timeout); } }; } @@ -1273,7 +1268,7 @@ export class Sandbox extends Container implements ISandbox { processId: string, command: string, pattern: string | RegExp, - timeout: number = 30_000 + timeout?: number ): Promise { const startTime = Date.now(); const conditionStr = this.conditionToString(pattern); @@ -1312,41 +1307,54 @@ export class Sandbox extends Container implements ISandbox { }); } - // Stream new logs and check for pattern with timeout + // Stream new logs and check for pattern const stream = await this.streamProcessLogs(processId); - // Create a timeout promise that rejects after remaining time - const remainingTime = timeout - (Date.now() - startTime); - if (remainingTime <= 0) { - throw this.createReadyTimeoutError( - processId, - command, - conditionStr, - timeout, - collectedStdout, - collectedStderr - ); - } - + // Set up timeout if specified let timeoutId: ReturnType | undefined; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject( - this.createReadyTimeoutError( - processId, - command, - conditionStr, - timeout, - collectedStdout, - collectedStderr - ) + let timeoutPromise: Promise | undefined; + + if (timeout !== undefined) { + const remainingTime = timeout - (Date.now() - startTime); + if (remainingTime <= 0) { + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout ); - }, remainingTime); - }); + } + + timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout + ) + ); + }, remainingTime); + }); + } try { - // Process stream with timeout + // Process stream const streamProcessor = async (): Promise => { + const DEBOUNCE_MS = 100; + let lastCheckTime = 0; + let pendingCheck = false; + + const checkPattern = (): WaitForLogResult | null => { + // Check both stdout and stderr buffers + const stdoutResult = this.matchPattern(collectedStdout, pattern); + if (stdoutResult) return stdoutResult; + const stderrResult = this.matchPattern(collectedStderr, pattern); + if (stderrResult) return stderrResult; + return null; + }; + for await (const event of parseSSEStream(stream)) { // Handle different event types if (event.type === 'stdout' || event.type === 'stderr') { @@ -1354,46 +1362,55 @@ export class Sandbox extends Container implements ISandbox { if (event.type === 'stdout') { collectedStdout += data; - // Check accumulated buffer for pattern (handles patterns split across chunks) - const result = this.matchPattern(collectedStdout, pattern); - if (result) { - return result; - } } else { collectedStderr += data; - // Check accumulated buffer for pattern (handles patterns split across chunks) - const result = this.matchPattern(collectedStderr, pattern); - if (result) { - return result; - } + } + pendingCheck = true; + + // Debounce pattern matching - check at most every 100ms + const now = Date.now(); + if (now - lastCheckTime >= DEBOUNCE_MS) { + lastCheckTime = now; + pendingCheck = false; + const result = checkPattern(); + if (result) return result; } } - // Process exited + // Process exited - do final check before throwing if (event.type === 'exit') { + if (pendingCheck) { + const result = checkPattern(); + if (result) return result; + } throw this.createExitedBeforeReadyError( processId, command, conditionStr, - event.exitCode ?? 1, - collectedStdout, - collectedStderr + event.exitCode ?? 1 ); } } - // Stream ended without finding pattern - throw this.createReadyTimeoutError( + // Stream ended - do final check before throwing + if (pendingCheck) { + const result = checkPattern(); + if (result) return result; + } + // Stream ended without finding pattern - this indicates process exited + throw this.createExitedBeforeReadyError( processId, command, conditionStr, - timeout, - collectedStdout, - collectedStderr + 0 ); }; - return await Promise.race([streamProcessor(), timeoutPromise]); + // Race with timeout if specified, otherwise just run stream processor + if (timeoutPromise) { + return await Promise.race([streamProcessor(), timeoutPromise]); + } + return await streamProcessor(); } finally { if (timeoutId) { clearTimeout(timeoutId); @@ -1408,26 +1425,39 @@ export class Sandbox extends Container implements ISandbox { processId: string, command: string, port: number, - timeout: number + timeout?: number ): Promise { const startTime = Date.now(); const conditionStr = `port ${port}`; const targetInterval = 500; // Target interval between checks - const execTimeout = 1000; // Timeout for each nc check + const execTimeout = 1000; // Timeout for each port check let checkCount = 0; while (true) { - const elapsed = Date.now() - startTime; - const remaining = timeout - elapsed; - - // Exit if we've exceeded timeout - if (remaining <= 0) { - break; - } + // Check timeout if specified + if (timeout !== undefined) { + const elapsed = Date.now() - startTime; + const remaining = timeout - elapsed; + + // Exit if we've exceeded timeout + if (remaining <= 0) { + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout + ); + } - // Skip check if remaining time is less than exec timeout - if (remaining < execTimeout) { - break; + // Skip check if remaining time is less than exec timeout + if (remaining < execTimeout) { + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout + ); + } } // Check process status less frequently (every 3rd iteration) to reduce latency @@ -1439,17 +1469,11 @@ export class Sandbox extends Container implements ISandbox { processInfo.status === 'failed' || processInfo.status === 'killed' ) { - const logs = await this.getProcessLogs(processId).catch(() => ({ - stdout: '', - stderr: '' - })); throw this.createExitedBeforeReadyError( processId, command, conditionStr, - processInfo?.exitCode ?? 1, - logs.stdout, - logs.stderr + processInfo?.exitCode ?? 1 ); } } @@ -1457,11 +1481,14 @@ export class Sandbox extends Container implements ISandbox { // Try to connect to the port using bash's /dev/tcp const checkStart = Date.now(); try { + const execTimeoutMs = + timeout !== undefined + ? Math.min(execTimeout, timeout - (Date.now() - startTime)) + : execTimeout; + const result = await this.exec( `bash -c 'echo > /dev/tcp/localhost/${port}' 2>/dev/null`, - { - timeout: Math.min(execTimeout, remaining) - } + { timeout: execTimeoutMs } ); if (result.exitCode === 0) { return; // Port is available @@ -1476,25 +1503,16 @@ export class Sandbox extends Container implements ISandbox { const checkDuration = Date.now() - checkStart; const sleepTime = Math.max(0, targetInterval - checkDuration); - // Only sleep if we have time remaining - if (sleepTime > 0 && Date.now() - startTime + sleepTime < timeout) { - await new Promise((resolve) => setTimeout(resolve, sleepTime)); + // Sleep between checks (skip if timeout would be exceeded) + if (sleepTime > 0) { + if ( + timeout === undefined || + Date.now() - startTime + sleepTime < timeout + ) { + await new Promise((resolve) => setTimeout(resolve, sleepTime)); + } } } - - // Timeout - const logs = await this.getProcessLogs(processId).catch(() => ({ - stdout: '', - stderr: '' - })); - throw this.createReadyTimeoutError( - processId, - command, - conditionStr, - timeout, - logs.stdout, - logs.stderr - ); } /** @@ -1551,9 +1569,7 @@ export class Sandbox extends Container implements ISandbox { processId: string, command: string, condition: string, - timeout: number, - stdout: string, - stderr: string + timeout: number ): ProcessReadyTimeoutError { return new ProcessReadyTimeoutError({ code: ErrorCode.PROCESS_READY_TIMEOUT, @@ -1562,9 +1578,7 @@ export class Sandbox extends Container implements ISandbox { processId, command, condition, - timeout, - stdout: stdout.slice(-2000), // Last 2000 chars - stderr: stderr.slice(-2000) + timeout }, httpStatus: 408, timestamp: new Date().toISOString(), @@ -1579,9 +1593,7 @@ export class Sandbox extends Container implements ISandbox { processId: string, command: string, condition: string, - exitCode: number, - stdout: string, - stderr: string + exitCode: number ): ProcessExitedBeforeReadyError { return new ProcessExitedBeforeReadyError({ code: ErrorCode.PROCESS_EXITED_BEFORE_READY, @@ -1590,13 +1602,11 @@ export class Sandbox extends Container implements ISandbox { processId, command, condition, - exitCode, - stdout: stdout.slice(-2000), - stderr: stderr.slice(-2000) + exitCode }, httpStatus: 500, timestamp: new Date().toISOString(), - suggestion: 'Check the process output above for error messages' + suggestion: 'Check process logs with getLogs() for error messages' }); } diff --git a/packages/sandbox/tests/process-readiness.test.ts b/packages/sandbox/tests/process-readiness.test.ts index 9dda0b45..f90da909 100644 --- a/packages/sandbox/tests/process-readiness.test.ts +++ b/packages/sandbox/tests/process-readiness.test.ts @@ -265,7 +265,7 @@ describe('Process Readiness Feature', () => { }); describe('timeout handling', () => { - it('should throw ProcessReadyTimeoutError when pattern not found', async () => { + it('should throw ProcessReadyTimeoutError when pattern not found within timeout', async () => { vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ success: true, processId: 'proc-server', @@ -294,10 +294,12 @@ describe('Process Readiness Feature', () => { timestamp: new Date().toISOString() } as any); - // Create an empty stream that closes immediately + // Create a stream that stays open longer than the timeout + // This ensures timeout fires before stream ends const mockStream = new ReadableStream({ start(controller) { - controller.close(); + // Keep stream open - never close it + // The timeout will fire before this stream ends } }); @@ -313,7 +315,7 @@ describe('Process Readiness Feature', () => { ); }); - it('should include captured logs in timeout error', async () => { + it('should include process info in timeout error', async () => { vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ success: true, processId: 'proc-server', @@ -342,9 +344,10 @@ describe('Process Readiness Feature', () => { timestamp: new Date().toISOString() } as any); + // Create a stream that stays open longer than the timeout const mockStream = new ReadableStream({ start(controller) { - controller.close(); + // Keep stream open - timeout will fire first } }); @@ -361,8 +364,6 @@ describe('Process Readiness Feature', () => { } catch (error) { expect(error).toBeInstanceOf(ProcessReadyTimeoutError); const readyError = error as ProcessReadyTimeoutError; - expect(readyError.stdout).toContain('Output line 1'); - expect(readyError.stderr).toContain('Error occurred'); expect(readyError.processId).toBe('proc-server'); expect(readyError.command).toBe('npm start'); } @@ -456,7 +457,6 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); const exitError = error as ProcessExitedBeforeReadyError; expect(exitError.exitCode).toBe(127); - expect(exitError.stderr).toContain('command not found'); expect(exitError.processId).toBe('proc-server'); } }); @@ -577,6 +577,7 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} timestamp: new Date().toISOString() } as any); + // Stream that closes immediately (simulates process exit) const mockStream = new ReadableStream({ start(controller) { controller.close(); @@ -590,12 +591,13 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} const proc = await sandbox.startProcess('npm start'); try { - await proc.waitForLog('Server ready', 50); + await proc.waitForLog('Server ready'); expect.fail('Should have thrown'); } catch (error) { - expect(error).toBeInstanceOf(ProcessReadyTimeoutError); - const readyError = error as ProcessReadyTimeoutError; - expect(readyError.condition).toBe('"Server ready"'); + // Stream ending means process exited + expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); + const exitError = error as ProcessExitedBeforeReadyError; + expect(exitError.condition).toBe('"Server ready"'); } }); @@ -628,6 +630,7 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} timestamp: new Date().toISOString() } as any); + // Stream that closes immediately (simulates process exit) const mockStream = new ReadableStream({ start(controller) { controller.close(); @@ -641,12 +644,13 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} const proc = await sandbox.startProcess('npm start'); try { - await proc.waitForLog(/port \d+/, 50); + await proc.waitForLog(/port \d+/); expect.fail('Should have thrown'); } catch (error) { - expect(error).toBeInstanceOf(ProcessReadyTimeoutError); - const readyError = error as ProcessReadyTimeoutError; - expect(readyError.condition).toBe('/port \\d+/'); + // Stream ending means process exited + expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); + const exitError = error as ProcessExitedBeforeReadyError; + expect(exitError.condition).toBe('/port \\d+/'); } }); }); diff --git a/packages/shared/src/errors/contexts.ts b/packages/shared/src/errors/contexts.ts index bcb70795..354f6e88 100644 --- a/packages/shared/src/errors/contexts.ts +++ b/packages/shared/src/errors/contexts.ts @@ -56,8 +56,6 @@ export interface ProcessReadyTimeoutContext { command: string; condition: string; timeout: number; - stdout?: string; - stderr?: string; } export interface ProcessExitedBeforeReadyContext { @@ -65,8 +63,6 @@ export interface ProcessExitedBeforeReadyContext { command: string; condition: string; exitCode: number; - stdout?: string; - stderr?: string; } /** From da1fd9b54a855bf4ca4b38b9ff02ec9e34441158 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Thu, 4 Dec 2025 23:23:16 +0000 Subject: [PATCH 09/12] some directional changes --- .../src/handlers/port-handler.ts | 20 +++ .../sandbox-container/src/routes/setup.ts | 7 + .../src/services/port-service.ts | 78 +++++++++- packages/sandbox/src/clients/port-client.ts | 29 +++- packages/sandbox/src/index.ts | 7 +- packages/sandbox/src/sandbox.ts | 68 +++++---- .../sandbox/tests/process-readiness.test.ts | 141 ++++++++++++++++-- packages/shared/src/index.ts | 3 + packages/shared/src/types.ts | 74 ++++++++- tests/e2e/test-worker/index.ts | 9 +- 10 files changed, 382 insertions(+), 54 deletions(-) diff --git a/packages/sandbox-container/src/handlers/port-handler.ts b/packages/sandbox-container/src/handlers/port-handler.ts index fb2bd651..2b040e40 100644 --- a/packages/sandbox-container/src/handlers/port-handler.ts +++ b/packages/sandbox-container/src/handlers/port-handler.ts @@ -1,6 +1,7 @@ // Port Handler import type { Logger, + PortCheckRequest, PortCloseResult, PortExposeResult, PortListResult @@ -25,6 +26,8 @@ export class PortHandler extends BaseHandler { 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/')) { @@ -51,6 +54,23 @@ export class PortHandler extends BaseHandler { ); } + private async handlePortCheck( + request: Request, + context: RequestContext + ): Promise { + const body = await this.parseRequestBody(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 diff --git a/packages/sandbox-container/src/routes/setup.ts b/packages/sandbox-container/src/routes/setup.ts index ee10a19a..c3bc6458 100644 --- a/packages/sandbox-container/src/routes/setup.ts +++ b/packages/sandbox-container/src/routes/setup.ts @@ -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', diff --git a/packages/sandbox-container/src/services/port-service.ts b/packages/sandbox-container/src/services/port-service.ts index 3063a38b..da2ca6e4 100644 --- a/packages/sandbox-container/src/services/port-service.ts +++ b/packages/sandbox-container/src/services/port-service.ts @@ -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, @@ -414,6 +414,82 @@ export class PortService { } } + /** + * Check if a port is ready to accept connections + * Supports both TCP and HTTP modes + */ + async checkPortReady(request: PortCheckRequest): Promise { + 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 { + try { + const socket = await Bun.connect({ + hostname: 'localhost', + port, + socket: { + data() {}, + open(socket) { + socket.end(); + }, + error() {}, + close() {} + } + }); + // 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 { + 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 () => { diff --git a/packages/sandbox/src/clients/port-client.ts b/packages/sandbox/src/clients/port-client.ts index f56e2f51..15572ac3 100644 --- a/packages/sandbox/src/clients/port-client.ts +++ b/packages/sandbox/src/clients/port-client.ts @@ -1,4 +1,6 @@ import type { + PortCheckRequest, + PortCheckResponse, PortCloseResult, PortExposeResult, PortListResult @@ -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 @@ -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 { + try { + const response = await this.post( + '/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' + }; + } + } } diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 2a077f39..ea964d0e 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -42,7 +42,8 @@ export type { SessionOptions, StreamOptions, // Process readiness types - WaitForLogResult + WaitForLogResult, + WaitForPortOptions } from '@repo/shared'; // Export type guards for runtime validation export { isExecResult, isProcess, isProcessStatus } from '@repo/shared'; @@ -98,10 +99,6 @@ export type { ExecutionCallbacks, InterpreterClient } from './clients/interpreter-client.js'; -export type { - ProcessExitedBeforeReadyContext, - ProcessReadyTimeoutContext -} from './errors'; // Export process readiness errors export { ProcessExitedBeforeReadyError, diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 611e9425..a75b0d52 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -12,6 +12,7 @@ import type { ISandbox, LogEvent, MountBucketOptions, + PortCheckRequest, Process, ProcessOptions, ProcessStatus, @@ -19,7 +20,8 @@ import type { SandboxOptions, SessionOptions, StreamOptions, - WaitForLogResult + WaitForLogResult, + WaitForPortOptions } from '@repo/shared'; import { createLogger, @@ -1255,8 +1257,11 @@ export class Sandbox extends Container implements ISandbox { return this.waitForLogPattern(data.id, data.command, pattern, timeout); }, - waitForPort: async (port: number, timeout?: number): Promise => { - await this.waitForPortReady(data.id, data.command, port, timeout); + waitForPort: async ( + port: number, + options?: WaitForPortOptions + ): Promise => { + await this.waitForPortReady(data.id, data.command, port, options); } }; } @@ -1425,14 +1430,35 @@ export class Sandbox extends Container implements ISandbox { processId: string, command: string, port: number, - timeout?: number + options?: WaitForPortOptions ): Promise { + const { + mode = 'http', + path = '/', + status = { min: 200, max: 399 }, + timeout, + interval = 500 + } = options ?? {}; + const startTime = Date.now(); - const conditionStr = `port ${port}`; - const targetInterval = 500; // Target interval between checks - const execTimeout = 1000; // Timeout for each port check + const conditionStr = + mode === 'http' ? `port ${port} (HTTP ${path})` : `port ${port} (TCP)`; + const targetInterval = interval; let checkCount = 0; + // Normalize status to min/max + const statusMin = typeof status === 'number' ? status : status.min; + const statusMax = typeof status === 'number' ? status : status.max; + + // Build the port check request + const checkRequest: PortCheckRequest = { + port, + mode, + path, + statusMin, + statusMax + }; + while (true) { // Check timeout if specified if (timeout !== undefined) { @@ -1448,16 +1474,6 @@ export class Sandbox extends Container implements ISandbox { timeout ); } - - // Skip check if remaining time is less than exec timeout - if (remaining < execTimeout) { - throw this.createReadyTimeoutError( - processId, - command, - conditionStr, - timeout - ); - } } // Check process status less frequently (every 3rd iteration) to reduce latency @@ -1478,20 +1494,12 @@ export class Sandbox extends Container implements ISandbox { } } - // Try to connect to the port using bash's /dev/tcp + // Check port readiness via container endpoint const checkStart = Date.now(); try { - const execTimeoutMs = - timeout !== undefined - ? Math.min(execTimeout, timeout - (Date.now() - startTime)) - : execTimeout; - - const result = await this.exec( - `bash -c 'echo > /dev/tcp/localhost/${port}' 2>/dev/null`, - { timeout: execTimeoutMs } - ); - if (result.exitCode === 0) { - return; // Port is available + const result = await this.client.ports.checkPortReady(checkRequest); + if (result.ready) { + return; // Port is ready } } catch { // Port not ready yet, continue polling @@ -1499,7 +1507,7 @@ export class Sandbox extends Container implements ISandbox { checkCount++; - // Calculate sleep time accounting for exec duration + // Calculate sleep time accounting for check duration const checkDuration = Date.now() - checkStart; const sleepTime = Math.max(0, targetInterval - checkDuration); diff --git a/packages/sandbox/tests/process-readiness.test.ts b/packages/sandbox/tests/process-readiness.test.ts index f90da909..70ed7e84 100644 --- a/packages/sandbox/tests/process-readiness.test.ts +++ b/packages/sandbox/tests/process-readiness.test.ts @@ -464,7 +464,7 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} }); describe('waitForPort() method', () => { - it('should wait for port to become available', async () => { + it('should wait for port to become available with HTTP mode (default)', async () => { vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ success: true, processId: 'proc-server', @@ -485,23 +485,96 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} timestamp: new Date().toISOString() } as any); - vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({ + vi.spyOn(sandbox.client.ports, 'checkPortReady').mockResolvedValue({ + ready: true, + statusCode: 200 + }); + + const proc = await sandbox.startProcess('npm start'); + await proc.waitForPort(3000); + + expect(sandbox.client.ports.checkPortReady).toHaveBeenCalledWith({ + port: 3000, + mode: 'http', + path: '/', + statusMin: 200, + statusMax: 399 + }); + }); + + it('should support TCP mode for non-HTTP services', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ success: true, - stdout: '', - stderr: '', - exitCode: 0, - command: "bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null", + processId: 'proc-db', + pid: 12345, + command: 'postgres', timestamp: new Date().toISOString() } as any); - const proc = await sandbox.startProcess('npm start'); - await proc.waitForPort(3000); + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-db', + pid: 12345, + command: 'postgres', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); - expect(sandbox.client.commands.execute).toHaveBeenCalledWith( - "bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null", - expect.any(String), - expect.objectContaining({ timeoutMs: 1000 }) - ); + vi.spyOn(sandbox.client.ports, 'checkPortReady').mockResolvedValue({ + ready: true + }); + + const proc = await sandbox.startProcess('postgres'); + await proc.waitForPort(5432, { mode: 'tcp' }); + + expect(sandbox.client.ports.checkPortReady).toHaveBeenCalledWith({ + port: 5432, + mode: 'tcp', + path: '/', + statusMin: 200, + statusMax: 399 + }); + }); + + it('should support custom health check path', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.ports, 'checkPortReady').mockResolvedValue({ + ready: true, + statusCode: 200 + }); + + const proc = await sandbox.startProcess('npm start'); + await proc.waitForPort(3000, { path: '/health', status: 200 }); + + expect(sandbox.client.ports.checkPortReady).toHaveBeenCalledWith({ + port: 3000, + mode: 'http', + path: '/health', + statusMin: 200, + statusMax: 200 + }); }); it('should throw ProcessExitedBeforeReadyError when process exits before port is ready', async () => { @@ -542,7 +615,47 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} } catch (error) { expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); const exitError = error as ProcessExitedBeforeReadyError; - expect(exitError.condition).toBe('port 3000'); + expect(exitError.condition).toBe('port 3000 (HTTP /)'); + } + }); + + it('should throw ProcessReadyTimeoutError when port does not become ready', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + // Port never becomes ready + vi.spyOn(sandbox.client.ports, 'checkPortReady').mockResolvedValue({ + ready: false, + error: 'Connection refused' + }); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitForPort(3000, { timeout: 100, interval: 50 }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessReadyTimeoutError); + const timeoutError = error as ProcessReadyTimeoutError; + expect(timeoutError.processId).toBe('proc-server'); + expect(timeoutError.condition).toBe('port 3000 (HTTP /)'); } }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3cad5417..80432b3f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -78,6 +78,8 @@ export type { MkdirResult, MountBucketOptions, MoveFileResult, + PortCheckRequest, + PortCheckResponse, PortCloseResult, // Port management result types PortExposeResult, @@ -105,6 +107,7 @@ export type { StreamOptions, // Process readiness types WaitForLogResult, + WaitForPortOptions, WriteFileResult } from './types.js'; export { isExecResult, isProcess, isProcessStatus } from './types.js'; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 3fe82de2..abf766a0 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -110,6 +110,67 @@ export interface WaitForLogResult { match?: RegExpMatchArray; } +/** + * Options for waiting for a port to become ready + */ +export interface WaitForPortOptions { + /** + * Check mode + * - 'http': Make an HTTP request and check for success status (default) + * - 'tcp': Just check if TCP connection succeeds + * @default 'http' + */ + mode?: 'http' | 'tcp'; + + /** + * HTTP path to check (only used when mode is 'http') + * @default '/' + */ + path?: string; + + /** + * Expected HTTP status code or range (only used when mode is 'http') + * - Single number: exact match (e.g., 200) + * - Object with min/max: range match (e.g., { min: 200, max: 399 }) + * @default { min: 200, max: 399 } + */ + status?: number | { min: number; max: number }; + + /** + * Maximum time to wait in milliseconds + * @default no timeout + */ + timeout?: number; + + /** + * Interval between checks in milliseconds + * @default 500 + */ + interval?: number; +} + +/** + * Request body for port readiness check endpoint + */ +export interface PortCheckRequest { + port: number; + mode: 'http' | 'tcp'; + path?: string; + statusMin?: number; + statusMax?: number; +} + +/** + * Response from port readiness check endpoint + */ +export interface PortCheckResponse { + ready: boolean; + /** HTTP status code received (only for http mode) */ + statusCode?: number; + /** Error message if check failed */ + error?: string; +} + // Background process types export interface ProcessOptions extends BaseExecOptions { /** @@ -222,13 +283,22 @@ export interface Process { ): Promise; /** - * Wait for a port to accept connections + * Wait for a port to become ready * * @example + * // Wait for HTTP endpoint to return 200-399 * const proc = await sandbox.startProcess("npm run dev"); * await proc.waitForPort(3000); + * + * @example + * // Wait for specific health endpoint + * await proc.waitForPort(3000, { path: '/health', status: 200 }); + * + * @example + * // TCP-only check (just verify port is accepting connections) + * await proc.waitForPort(5432, { mode: 'tcp' }); */ - waitForPort(port: number, timeout?: number): Promise; + waitForPort(port: number, options?: WaitForPortOptions): Promise; } // Streaming event types diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index 802cecdb..d8f5b776 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -556,7 +556,14 @@ console.log('Terminal server on port ' + port); headers: { 'Content-Type': 'application/json' } }); } - await process.waitForPort(body.port, body.timeout); + // Build WaitForPortOptions from request body + await process.waitForPort(body.port, { + mode: body.mode, + path: body.path, + status: body.status, + timeout: body.timeout, + interval: body.interval + }); return new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } }); From fba20770040bbe40a31d34f76bd8be363893cc85 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Thu, 4 Dec 2025 23:41:32 +0000 Subject: [PATCH 10/12] more nits and picks --- .../src/services/port-service.ts | 13 +++++++- packages/sandbox/src/sandbox.ts | 31 ++++++++++--------- packages/shared/src/index.ts | 7 ++++- packages/shared/src/types.ts | 12 +++++++ 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/packages/sandbox-container/src/services/port-service.ts b/packages/sandbox-container/src/services/port-service.ts index da2ca6e4..35246632 100644 --- a/packages/sandbox-container/src/services/port-service.ts +++ b/packages/sandbox-container/src/services/port-service.ts @@ -435,8 +435,17 @@ export class PortService { } private async checkTcpReady(port: number): Promise { + const TCP_TIMEOUT_MS = 5000; // 5 second timeout matching HTTP check + try { - const socket = await Bun.connect({ + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error('TCP connection timeout')), + TCP_TIMEOUT_MS + ); + }); + + const connectPromise = Bun.connect({ hostname: 'localhost', port, socket: { @@ -448,6 +457,8 @@ export class PortService { close() {} } }); + + const socket = await Promise.race([connectPromise, timeoutPromise]); // Connection succeeded socket.end(); return { ready: true }; diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index a75b0d52..a1902f92 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -26,6 +26,7 @@ import type { import { createLogger, getEnvString, + isTerminalStatus, type SessionDeleteResult, shellEscape, TraceContext @@ -1347,7 +1348,7 @@ export class Sandbox extends Container implements ISandbox { try { // Process stream const streamProcessor = async (): Promise => { - const DEBOUNCE_MS = 100; + const DEBOUNCE_MS = 50; let lastCheckTime = 0; let pendingCheck = false; @@ -1372,7 +1373,7 @@ export class Sandbox extends Container implements ISandbox { } pendingCheck = true; - // Debounce pattern matching - check at most every 100ms + // Debounce pattern matching - check at most every 50ms const now = Date.now(); if (now - lastCheckTime >= DEBOUNCE_MS) { lastCheckTime = now; @@ -1476,15 +1477,13 @@ export class Sandbox extends Container implements ISandbox { } } + // Track total operation time for accurate sleep calculation + const iterationStart = Date.now(); + // Check process status less frequently (every 3rd iteration) to reduce latency if (checkCount % 3 === 0) { const processInfo = await this.getProcess(processId); - if ( - !processInfo || - processInfo.status === 'completed' || - processInfo.status === 'failed' || - processInfo.status === 'killed' - ) { + if (!processInfo || isTerminalStatus(processInfo.status)) { throw this.createExitedBeforeReadyError( processId, command, @@ -1495,7 +1494,6 @@ export class Sandbox extends Container implements ISandbox { } // Check port readiness via container endpoint - const checkStart = Date.now(); try { const result = await this.client.ports.checkPortReady(checkRequest); if (result.ready) { @@ -1507,9 +1505,9 @@ export class Sandbox extends Container implements ISandbox { checkCount++; - // Calculate sleep time accounting for check duration - const checkDuration = Date.now() - checkStart; - const sleepTime = Math.max(0, targetInterval - checkDuration); + // Calculate sleep time accounting for total iteration duration (process check + port check) + const iterationDuration = Date.now() - iterationStart; + const sleepTime = Math.max(0, targetInterval - iterationDuration); // Sleep between checks (skip if timeout would be exceeded) if (sleepTime > 0) { @@ -1543,13 +1541,16 @@ export class Sandbox extends Container implements ISandbox { return { line: pattern }; } } else { - // Regex match - const match = text.match(pattern); + const safePattern = new RegExp( + pattern.source, + pattern.flags.replace('g', '') + ); + const match = text.match(safePattern); if (match) { // Find the full line containing the match const lines = text.split('\n'); for (const line of lines) { - const lineMatch = line.match(pattern); + const lineMatch = line.match(safePattern); if (lineMatch) { return { line, match: lineMatch }; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 80432b3f..2e0362e3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -110,4 +110,9 @@ export type { WaitForPortOptions, WriteFileResult } from './types.js'; -export { isExecResult, isProcess, isProcessStatus } from './types.js'; +export { + isExecResult, + isProcess, + isProcessStatus, + isTerminalStatus +} from './types.js'; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index abf766a0..f35941d7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -213,6 +213,18 @@ export type ProcessStatus = | 'killed' // Process was terminated by signal | 'error'; // Process failed to start or encountered error +/** + * Check if a process status indicates the process has terminated + */ +export function isTerminalStatus(status: ProcessStatus): boolean { + return ( + status === 'completed' || + status === 'failed' || + status === 'killed' || + status === 'error' + ); +} + export interface Process { /** * Unique process identifier From bb7006a7a71cfbecdbf65e2255147db6a10a469c Mon Sep 17 00:00:00 2001 From: whoiskatrin Date: Thu, 4 Dec 2025 23:49:41 +0000 Subject: [PATCH 11/12] add changeset The `Process` object now includes methods to detect readiness based on port and log patterns, enhancing process management. --- .changeset/mighty-squids-count.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/mighty-squids-count.md diff --git a/.changeset/mighty-squids-count.md b/.changeset/mighty-squids-count.md new file mode 100644 index 00000000..245d4fbd --- /dev/null +++ b/.changeset/mighty-squids-count.md @@ -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 From 2c704301eb1d7c0489107775dab9d3c5ca20e7cb Mon Sep 17 00:00:00 2001 From: Naresh Date: Fri, 5 Dec 2025 10:48:48 +0000 Subject: [PATCH 12/12] Remove internal detail to avoid confusions --- .changeset/mighty-squids-count.md | 18 ++++++------ package-lock.json | 46 ++++++++++--------------------- 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/.changeset/mighty-squids-count.md b/.changeset/mighty-squids-count.md index 245d4fbd..d99943c7 100644 --- a/.changeset/mighty-squids-count.md +++ b/.changeset/mighty-squids-count.md @@ -1,15 +1,15 @@ --- -"@cloudflare/sandbox": patch +'@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.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 + - 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 +- `process.waitForLog(pattern, options?)`: Wait for pattern in process output + - Supports string or RegExp patterns + - Returns matching line and capture groups diff --git a/package-lock.json b/package-lock.json index 4b8603b4..ed9afb07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -401,7 +401,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -423,7 +422,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -563,7 +561,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1509,8 +1506,7 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251126.0.tgz", "integrity": "sha512-DSeI1Q7JYmh5/D/tw5eZCjrKY34v69rwj63hHt60nSQW5QLwWCbj/lLtNz9f2EPa+JCACwpLXHgCXfzJ29x66w==", "devOptional": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -2824,7 +2820,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -4304,7 +4299,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4320,7 +4314,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4330,7 +4323,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4457,7 +4449,6 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -4473,7 +4464,6 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -4502,7 +4492,6 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -4650,7 +4639,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5396,7 +5384,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5690,7 +5677,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -7625,7 +7611,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -7890,7 +7875,6 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "devOptional": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -7927,6 +7911,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7947,6 +7932,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7967,6 +7953,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7987,6 +7974,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8007,6 +7995,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8027,6 +8016,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8047,6 +8037,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8067,6 +8058,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8087,6 +8079,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8107,6 +8100,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8127,6 +8121,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8176,6 +8171,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9744,7 +9740,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9961,7 +9956,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9971,7 +9965,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9983,7 +9976,8 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-katex": { "version": "3.1.0", @@ -10357,7 +10351,6 @@ "integrity": "sha512-ZRLgPlS91l4JztLYEZnmMcd3Umcla1hkXJgiEiR4HloRJBBoeaX8qogTu5Jfu36rRMVLndzqYv0h+M5gJAkUfg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.98.0", "@rolldown/pluginutils": "1.0.0-beta.51" @@ -11173,7 +11166,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11373,7 +11365,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -11533,7 +11524,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11603,7 +11593,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -12045,7 +12034,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -12160,7 +12148,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12193,7 +12180,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -12619,7 +12605,6 @@ "integrity": "sha512-Om5ns0Lyx/LKtYI04IV0bjIrkBgoFNg0p6urzr2asekJlfP18RqFzyqMFZKf0i9Gnjtz/JfAS/Ol6tjCe5JJsQ==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -13383,7 +13368,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }