Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/add-exists-method.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cloudflare/sandbox": patch
---

Add exists() method to check if a file or directory exists

This adds a new `exists()` method to the SDK that checks whether a file or directory exists at a given path. The method returns a boolean indicating existence, similar to Python's `os.path.exists()` and JavaScript's `fs.existsSync()`.

The implementation is end-to-end:
- New `FileExistsResult` and `FileExistsRequest` types in shared package
- Handler endpoint at `/api/exists` in container layer
- Client method in `FileClient` and `Sandbox` classes
- Full test coverage (unit tests and E2E tests)
25 changes: 24 additions & 1 deletion packages/sandbox-container/src/handlers/file-handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type {
DeleteFileResult,
FileExistsRequest,
FileExistsResult,
FileStreamEvent,
ListFilesResult,Logger,
ListFilesResult,Logger,
MkdirResult,
MoveFileResult,
ReadFileResult,
Expand Down Expand Up @@ -52,6 +54,8 @@ export class FileHandler extends BaseHandler<Request, Response> {
return await this.handleMkdir(request, context);
case '/api/list-files':
return await this.handleListFiles(request, context);
case '/api/exists':
return await this.handleExists(request, context);
default:
return this.createErrorResponse({
message: 'Invalid file endpoint',
Expand Down Expand Up @@ -277,4 +281,23 @@ export class FileHandler extends BaseHandler<Request, Response> {
return this.createErrorResponse(result.error, context);
}
}

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

const result = await this.fileService.exists(body.path, body.sessionId);

if (result.success) {
const response: FileExistsResult = {
success: true,
path: body.path,
exists: result.data,
timestamp: new Date().toISOString(),
};

return this.createTypedResponse(response, context);
} else {
return this.createErrorResponse(result.error, context);
}
}
}
7 changes: 7 additions & 0 deletions packages/sandbox-container/src/routes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ export function setupRoutes(router: Router, container: Container): void {
middleware: [container.get('loggingMiddleware')],
});

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

// Port management routes
router.register({
method: 'POST',
Expand Down
85 changes: 85 additions & 0 deletions packages/sandbox-container/tests/handlers/file-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "bun:test";
import type {
DeleteFileResult,
FileExistsResult,
MkdirResult,
MoveFileResult,
ReadFileResult,
Expand Down Expand Up @@ -486,6 +487,90 @@ describe('FileHandler', () => {
});
});

describe('handleExists - POST /api/exists', () => {
it('should return true when file exists', async () => {
const existsData = {
path: '/tmp/test.txt',
sessionId: 'session-123'
};

(mockFileService.exists as any).mockResolvedValue({
success: true,
data: true
});

const request = new Request('http://localhost:3000/api/exists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(existsData)
});

const response = await fileHandler.handle(request, mockContext);

expect(response.status).toBe(200);
const responseData = await response.json() as FileExistsResult;
expect(responseData.success).toBe(true);
expect(responseData.exists).toBe(true);
expect(responseData.path).toBe('/tmp/test.txt');
expect(responseData.timestamp).toBeDefined();

expect(mockFileService.exists).toHaveBeenCalledWith('/tmp/test.txt', 'session-123');
});

it('should return false when file does not exist', async () => {
const existsData = {
path: '/tmp/nonexistent.txt',
sessionId: 'session-123'
};

(mockFileService.exists as any).mockResolvedValue({
success: true,
data: false
});

const request = new Request('http://localhost:3000/api/exists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(existsData)
});

const response = await fileHandler.handle(request, mockContext);

expect(response.status).toBe(200);
const responseData = await response.json() as FileExistsResult;
expect(responseData.success).toBe(true);
expect(responseData.exists).toBe(false);
});

it('should handle errors when checking file existence', async () => {
const existsData = {
path: '/invalid/path',
sessionId: 'session-123'
};

(mockFileService.exists as any).mockResolvedValue({
success: false,
error: {
message: 'Invalid path',
code: 'VALIDATION_FAILED',
httpStatus: 400
}
});

const request = new Request('http://localhost:3000/api/exists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(existsData)
});

const response = await fileHandler.handle(request, mockContext);

expect(response.status).toBe(400);
const responseData = await response.json() as ErrorResponse;
expect(responseData.code).toBe('VALIDATION_FAILED');
});
});

describe('route handling', () => {
it('should return 500 for invalid endpoints', async () => {
const request = new Request('http://localhost:3000/api/invalid-operation', {
Expand Down
26 changes: 26 additions & 0 deletions packages/sandbox/src/clients/file-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
DeleteFileResult,
FileExistsResult,
ListFilesOptions,
ListFilesResult,
MkdirResult,
Expand Down Expand Up @@ -266,4 +267,29 @@ export class FileClient extends BaseHttpClient {
throw error;
}
}

/**
* Check if a file or directory exists
* @param path - Path to check
* @param sessionId - The session ID for this operation
*/
async exists(
path: string,
sessionId: string
): Promise<FileExistsResult> {
try {
const data = {
path,
sessionId,
};

const response = await this.post<FileExistsResult>('/api/exists', data);

this.logSuccess('Path existence checked', `${path} (exists: ${response.exists})`);
return response;
} catch (error) {
this.logError('exists', error);
throw error;
}
}
}
6 changes: 6 additions & 0 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
return this.client.files.listFiles(path, session, options);
}

async exists(path: string, sessionId?: string) {
const session = sessionId ?? await this.ensureDefaultSession();
return this.client.files.exists(path, session);
}

async exposePort(port: number, options: { name?: string; hostname: string }) {
// Check if hostname is workers.dev domain (doesn't support wildcard subdomains)
if (options.hostname.endsWith('.workers.dev')) {
Expand Down Expand Up @@ -934,6 +939,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
renameFile: (oldPath, newPath) => this.renameFile(oldPath, newPath, sessionId),
moveFile: (sourcePath, destPath) => this.moveFile(sourcePath, destPath, sessionId),
listFiles: (path, options) => this.client.files.listFiles(path, sessionId, options),
exists: (path) => this.exists(path, sessionId),

// Git operations
gitCheckout: (repoUrl, options) => this.gitCheckout(repoUrl, { ...options, sessionId }),
Expand Down
76 changes: 76 additions & 0 deletions packages/sandbox/tests/file-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
DeleteFileResult,
FileExistsResult,
ListFilesResult,
MkdirResult,
MoveFileResult,
Expand Down Expand Up @@ -584,6 +585,81 @@ database:
});
});

describe('exists', () => {
it('should return true when file exists', async () => {
const mockResponse: FileExistsResult = {
success: true,
path: '/workspace/test.txt',
exists: true,
timestamp: '2023-01-01T00:00:00Z',
};

mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));

const result = await client.exists('/workspace/test.txt', 'session-exists');

expect(result.success).toBe(true);
expect(result.exists).toBe(true);
expect(result.path).toBe('/workspace/test.txt');
});

it('should return false when file does not exist', async () => {
const mockResponse: FileExistsResult = {
success: true,
path: '/workspace/nonexistent.txt',
exists: false,
timestamp: '2023-01-01T00:00:00Z',
};

mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));

const result = await client.exists('/workspace/nonexistent.txt', 'session-exists');

expect(result.success).toBe(true);
expect(result.exists).toBe(false);
});

it('should return true when directory exists', async () => {
const mockResponse: FileExistsResult = {
success: true,
path: '/workspace/some-dir',
exists: true,
timestamp: '2023-01-01T00:00:00Z',
};

mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));

const result = await client.exists('/workspace/some-dir', 'session-exists');

expect(result.success).toBe(true);
expect(result.exists).toBe(true);
});

it('should send correct request payload', async () => {
const mockResponse: FileExistsResult = {
success: true,
path: '/test/path',
exists: true,
timestamp: '2023-01-01T00:00:00Z',
};

mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));

await client.exists('/test/path', 'session-test');

expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/exists'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
path: '/test/path',
sessionId: 'session-test',
})
})
);
});
});

describe('error handling', () => {
it('should handle network failures gracefully', async () => {
mockFetch.mockRejectedValue(new Error('Network connection failed'));
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type {
DeleteFileRequest,
ExecuteRequest,
ExposePortRequest,
FileExistsRequest,
GitCheckoutRequest,
MkdirRequest,
MoveFileRequest,
Expand All @@ -53,6 +54,7 @@ export type {
ExecOptions,
ExecResult,
ExecutionSession,
FileExistsResult,
// File streaming types
FileChunk,
FileInfo,
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/request-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ export interface MkdirRequest {
sessionId?: string;
}

/**
* Request to check if a file or directory exists
*/
export interface FileExistsRequest {
path: string;
sessionId?: string;
}

/**
* Request to expose a port
*/
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,13 @@ export interface MoveFileResult {
exitCode?: number;
}

export interface FileExistsResult {
success: boolean;
path: string;
exists: boolean;
timestamp: string;
}

export interface FileInfo {
name: string;
absolutePath: string;
Expand Down Expand Up @@ -603,6 +610,7 @@ export interface ExecutionSession {
renameFile(oldPath: string, newPath: string): Promise<RenameFileResult>;
moveFile(sourcePath: string, destinationPath: string): Promise<MoveFileResult>;
listFiles(path: string, options?: ListFilesOptions): Promise<ListFilesResult>;
exists(path: string): Promise<FileExistsResult>;

// Git operations
gitCheckout(repoUrl: string, options?: { branch?: string; targetDir?: string }): Promise<GitCheckoutResult>;
Expand Down Expand Up @@ -647,6 +655,7 @@ export interface ISandbox {
renameFile(oldPath: string, newPath: string): Promise<RenameFileResult>;
moveFile(sourcePath: string, destinationPath: string): Promise<MoveFileResult>;
listFiles(path: string, options?: ListFilesOptions): Promise<ListFilesResult>;
exists(path: string, sessionId?: string): Promise<FileExistsResult>;

// Git operations
gitCheckout(repoUrl: string, options?: { branch?: string; targetDir?: string }): Promise<GitCheckoutResult>;
Expand Down
Loading