539 lines
14 KiB
TypeScript
539 lines
14 KiB
TypeScript
import { Sandbox } from '@vercel/sandbox';
|
|
import { SandboxProvider, SandboxInfo, CommandResult } from '../types';
|
|
// SandboxProviderConfig available through parent class
|
|
|
|
export class VercelProvider extends SandboxProvider {
|
|
private existingFiles: Set<string> = new Set();
|
|
|
|
async createSandbox(): Promise<SandboxInfo> {
|
|
try {
|
|
|
|
// Kill existing sandbox if any
|
|
if (this.sandbox) {
|
|
try {
|
|
await this.sandbox.stop();
|
|
} catch (e) {
|
|
console.error('Failed to stop existing sandbox:', e);
|
|
}
|
|
this.sandbox = null;
|
|
}
|
|
|
|
// Clear existing files tracking
|
|
this.existingFiles.clear();
|
|
|
|
// Create Vercel sandbox
|
|
|
|
const sandboxConfig: any = {
|
|
timeout: 300000, // 5 minutes in ms
|
|
runtime: 'node22', // Use node22 runtime for Vercel sandboxes
|
|
ports: [5173] // Vite port
|
|
};
|
|
|
|
// Add authentication based on environment variables
|
|
if (process.env.VERCEL_TOKEN && process.env.VERCEL_TEAM_ID && process.env.VERCEL_PROJECT_ID) {
|
|
sandboxConfig.teamId = process.env.VERCEL_TEAM_ID;
|
|
sandboxConfig.projectId = process.env.VERCEL_PROJECT_ID;
|
|
sandboxConfig.token = process.env.VERCEL_TOKEN;
|
|
} else if (process.env.VERCEL_OIDC_TOKEN) {
|
|
} else {
|
|
}
|
|
|
|
runtime: sandboxConfig.runtime,
|
|
timeout: sandboxConfig.timeout,
|
|
ports: sandboxConfig.ports,
|
|
hasTeamId: !!sandboxConfig.teamId,
|
|
hasProjectId: !!sandboxConfig.projectId,
|
|
hasToken: !!sandboxConfig.token
|
|
});
|
|
|
|
this.sandbox = await Sandbox.create(sandboxConfig);
|
|
|
|
const sandboxId = this.sandbox.sandboxId;
|
|
sandboxId: sandboxId,
|
|
status: this.sandbox.status
|
|
});
|
|
|
|
// Get the sandbox URL using the correct Vercel Sandbox API
|
|
const sandboxUrl = this.sandbox.domain(5173);
|
|
|
|
this.sandboxInfo = {
|
|
sandboxId,
|
|
url: sandboxUrl,
|
|
provider: 'vercel',
|
|
createdAt: new Date()
|
|
};
|
|
|
|
return this.sandboxInfo;
|
|
|
|
} catch (error) {
|
|
console.error('[VercelProvider] Error creating sandbox:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async runCommand(command: string): Promise<CommandResult> {
|
|
if (!this.sandbox) {
|
|
throw new Error('No active sandbox');
|
|
}
|
|
|
|
|
|
try {
|
|
// Parse command into cmd and args (matching PR syntax)
|
|
const parts = command.split(' ');
|
|
const cmd = parts[0];
|
|
const args = parts.slice(1);
|
|
|
|
// Vercel uses runCommand with cmd and args object (based on PR)
|
|
const result = await this.sandbox.runCommand({
|
|
cmd: cmd,
|
|
args: args,
|
|
cwd: '/vercel/sandbox',
|
|
env: {}
|
|
});
|
|
|
|
return {
|
|
stdout: result.stdout || '',
|
|
stderr: result.stderr || '',
|
|
exitCode: result.exitCode || 0,
|
|
success: result.exitCode === 0
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
stdout: '',
|
|
stderr: error.message || 'Command failed',
|
|
exitCode: 1,
|
|
success: false
|
|
};
|
|
}
|
|
}
|
|
|
|
async writeFile(path: string, content: string): Promise<void> {
|
|
if (!this.sandbox) {
|
|
throw new Error('No active sandbox');
|
|
}
|
|
|
|
// Vercel sandbox default working directory is /vercel/sandbox
|
|
const fullPath = path.startsWith('/') ? path : `/vercel/sandbox/${path}`;
|
|
|
|
originalPath: path,
|
|
fullPath: fullPath,
|
|
contentLength: content.length,
|
|
contentPreview: content.substring(0, 100) + (content.length > 100 ? '...' : ''),
|
|
sandboxId: this.sandbox.sandboxId,
|
|
sandboxStatus: this.sandbox.status
|
|
});
|
|
|
|
// Based on Vercel SDK docs, writeFiles expects path and Buffer content
|
|
try {
|
|
const buffer = Buffer.from(content, 'utf-8');
|
|
path: fullPath,
|
|
bufferLength: buffer.length,
|
|
isBuffer: Buffer.isBuffer(buffer)
|
|
});
|
|
|
|
await this.sandbox.writeFiles([{
|
|
path: fullPath,
|
|
content: buffer
|
|
}]);
|
|
|
|
this.existingFiles.add(path);
|
|
} catch (writeError: any) {
|
|
// Log detailed error information
|
|
console.error(`[VercelProvider] writeFiles failed for ${fullPath}:`, {
|
|
error: writeError,
|
|
message: writeError?.message,
|
|
response: writeError?.response,
|
|
statusCode: writeError?.response?.status,
|
|
responseData: writeError?.response?.data
|
|
});
|
|
|
|
// Fallback to command-based approach if writeFiles fails
|
|
|
|
// Ensure directory exists
|
|
const dir = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
|
if (dir) {
|
|
const mkdirResult = await this.sandbox.runCommand({
|
|
cmd: 'mkdir',
|
|
args: ['-p', dir]
|
|
});
|
|
exitCode: mkdirResult.exitCode,
|
|
stdout: mkdirResult.stdout,
|
|
stderr: mkdirResult.stderr
|
|
});
|
|
}
|
|
|
|
// Write file using echo and redirection
|
|
const escapedContent = content
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/"/g, '\\"')
|
|
.replace(/\$/g, '\\$')
|
|
.replace(/`/g, '\\`')
|
|
.replace(/\n/g, '\\n');
|
|
|
|
const writeResult = await this.sandbox.runCommand({
|
|
cmd: 'sh',
|
|
args: ['-c', `echo "${escapedContent}" > "${fullPath}"`]
|
|
});
|
|
|
|
exitCode: writeResult.exitCode,
|
|
stdout: writeResult.stdout,
|
|
stderr: writeResult.stderr
|
|
});
|
|
|
|
if (writeResult.exitCode === 0) {
|
|
this.existingFiles.add(path);
|
|
} else {
|
|
throw new Error(`Failed to write file via command: ${writeResult.stderr}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async readFile(path: string): Promise<string> {
|
|
if (!this.sandbox) {
|
|
throw new Error('No active sandbox');
|
|
}
|
|
|
|
// Vercel sandbox default working directory is /vercel/sandbox
|
|
const fullPath = path.startsWith('/') ? path : `/vercel/sandbox/${path}`;
|
|
|
|
const result = await this.sandbox.runCommand({
|
|
cmd: 'cat',
|
|
args: [fullPath]
|
|
});
|
|
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(`Failed to read file: ${result.stderr}`);
|
|
}
|
|
|
|
return result.stdout || '';
|
|
}
|
|
|
|
async listFiles(directory: string = '/vercel/sandbox'): Promise<string[]> {
|
|
if (!this.sandbox) {
|
|
throw new Error('No active sandbox');
|
|
}
|
|
|
|
const result = await this.sandbox.runCommand({
|
|
cmd: 'sh',
|
|
args: ['-c', `find ${directory} -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -not -path "*/dist/*" -not -path "*/build/*" | sed "s|^${directory}/||"`],
|
|
cwd: '/'
|
|
});
|
|
|
|
if (result.exitCode !== 0) {
|
|
return [];
|
|
}
|
|
|
|
return (result.stdout || '').split('\n').filter((line: string) => line.trim() !== '');
|
|
}
|
|
|
|
async installPackages(packages: string[]): Promise<CommandResult> {
|
|
if (!this.sandbox) {
|
|
throw new Error('No active sandbox');
|
|
}
|
|
|
|
const flags = process.env.NPM_FLAGS || '';
|
|
|
|
|
|
// Build args array
|
|
const args = ['install'];
|
|
if (flags) {
|
|
args.push(...flags.split(' '));
|
|
}
|
|
args.push(...packages);
|
|
|
|
const result = await this.sandbox.runCommand({
|
|
cmd: 'npm',
|
|
args: args,
|
|
cwd: '/vercel/sandbox'
|
|
});
|
|
|
|
// Restart Vite if configured and successful
|
|
if (result.exitCode === 0 && process.env.AUTO_RESTART_VITE === 'true') {
|
|
await this.restartViteServer();
|
|
}
|
|
|
|
return {
|
|
stdout: result.stdout || '',
|
|
stderr: result.stderr || '',
|
|
exitCode: result.exitCode || 0,
|
|
success: result.exitCode === 0
|
|
};
|
|
}
|
|
|
|
async setupViteApp(): Promise<void> {
|
|
if (!this.sandbox) {
|
|
throw new Error('No active sandbox');
|
|
}
|
|
|
|
sandboxId: this.sandbox.sandboxId,
|
|
status: this.sandbox.status
|
|
});
|
|
|
|
// Create directory structure
|
|
const mkdirResult = await this.sandbox.runCommand({
|
|
cmd: 'mkdir',
|
|
args: ['-p', '/vercel/sandbox/src']
|
|
});
|
|
exitCode: mkdirResult.exitCode,
|
|
stdout: mkdirResult.stdout,
|
|
stderr: mkdirResult.stderr
|
|
});
|
|
|
|
// Create package.json
|
|
const packageJson = {
|
|
name: "sandbox-app",
|
|
version: "1.0.0",
|
|
type: "module",
|
|
scripts: {
|
|
dev: "vite --host",
|
|
build: "vite build",
|
|
preview: "vite preview"
|
|
},
|
|
dependencies: {
|
|
react: "^18.2.0",
|
|
"react-dom": "^18.2.0"
|
|
},
|
|
devDependencies: {
|
|
"@vitejs/plugin-react": "^4.0.0",
|
|
vite: "^4.3.9",
|
|
tailwindcss: "^3.3.0",
|
|
postcss: "^8.4.31",
|
|
autoprefixer: "^10.4.16"
|
|
}
|
|
};
|
|
|
|
await this.writeFile('package.json', JSON.stringify(packageJson, null, 2));
|
|
|
|
// Create vite.config.js
|
|
const viteConfig = `import { defineConfig } from 'vite'
|
|
import react from '@vitejs/plugin-react'
|
|
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
server: {
|
|
host: '0.0.0.0',
|
|
port: 5173,
|
|
strictPort: true,
|
|
allowedHosts: [
|
|
'.vercel.run', // Allow all Vercel sandbox domains
|
|
'.e2b.dev', // Allow all E2B sandbox domains
|
|
'localhost'
|
|
],
|
|
hmr: {
|
|
clientPort: 443,
|
|
protocol: 'wss'
|
|
}
|
|
}
|
|
})`;
|
|
|
|
await this.writeFile('vite.config.js', viteConfig);
|
|
|
|
// Create tailwind.config.js
|
|
const tailwindConfig = `/** @type {import('tailwindcss').Config} */
|
|
export default {
|
|
content: [
|
|
"./index.html",
|
|
"./src/**/*.{js,ts,jsx,tsx}",
|
|
],
|
|
theme: {
|
|
extend: {},
|
|
},
|
|
plugins: [],
|
|
}`;
|
|
|
|
await this.writeFile('tailwind.config.js', tailwindConfig);
|
|
|
|
// Create postcss.config.js
|
|
const postcssConfig = `export default {
|
|
plugins: {
|
|
tailwindcss: {},
|
|
autoprefixer: {},
|
|
},
|
|
}`;
|
|
|
|
await this.writeFile('postcss.config.js', postcssConfig);
|
|
|
|
// Create index.html
|
|
const indexHtml = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Sandbox App</title>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module" src="/src/main.jsx"></script>
|
|
</body>
|
|
</html>`;
|
|
|
|
await this.writeFile('index.html', indexHtml);
|
|
|
|
// Create src/main.jsx
|
|
const mainJsx = `import React from 'react'
|
|
import ReactDOM from 'react-dom/client'
|
|
import App from './App.jsx'
|
|
import './index.css'
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
<React.StrictMode>
|
|
<App />
|
|
</React.StrictMode>,
|
|
)`;
|
|
|
|
await this.writeFile('src/main.jsx', mainJsx);
|
|
|
|
// Create src/App.jsx
|
|
const appJsx = `function App() {
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center p-4">
|
|
<div className="text-center max-w-2xl">
|
|
<p className="text-lg text-gray-400">
|
|
Vercel Sandbox Ready<br/>
|
|
Start building your React app with Vite and Tailwind CSS!
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App`;
|
|
|
|
await this.writeFile('src/App.jsx', appJsx);
|
|
|
|
// Create src/index.css
|
|
const indexCss = `@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
background-color: rgb(17 24 39);
|
|
}`;
|
|
|
|
await this.writeFile('src/index.css', indexCss);
|
|
|
|
|
|
// Install dependencies
|
|
try {
|
|
const installResult = await this.sandbox.runCommand({
|
|
cmd: 'npm',
|
|
args: ['install'],
|
|
cwd: '/vercel/sandbox'
|
|
});
|
|
|
|
exitCode: installResult.exitCode,
|
|
stdout: typeof installResult.stdout === 'function' ? 'function' : installResult.stdout,
|
|
stderr: typeof installResult.stderr === 'function' ? 'function' : installResult.stderr
|
|
});
|
|
|
|
if (installResult.exitCode === 0) {
|
|
} else {
|
|
console.warn('[VercelProvider] npm install had issues:', installResult.stderr);
|
|
}
|
|
} catch (error: any) {
|
|
console.error('[VercelProvider] npm install error:', {
|
|
message: error?.message,
|
|
response: error?.response?.status,
|
|
responseText: error?.text
|
|
});
|
|
// Try alternative approach - run as shell command
|
|
try {
|
|
const altResult = await this.sandbox.runCommand({
|
|
cmd: 'sh',
|
|
args: ['-c', 'cd /vercel/sandbox && npm install'],
|
|
cwd: '/vercel/sandbox'
|
|
});
|
|
if (altResult.exitCode === 0) {
|
|
} else {
|
|
console.warn('[VercelProvider] Alternative npm install also had issues:', altResult.stderr);
|
|
}
|
|
} catch (altError) {
|
|
console.error('[VercelProvider] Alternative npm install also failed:', altError);
|
|
console.warn('[VercelProvider] Continuing without npm install - packages may need to be installed manually');
|
|
}
|
|
}
|
|
|
|
// Start Vite dev server
|
|
|
|
// Kill any existing Vite processes
|
|
await this.sandbox.runCommand({
|
|
cmd: 'sh',
|
|
args: ['-c', 'pkill -f vite || true'],
|
|
cwd: '/'
|
|
});
|
|
|
|
// Start Vite in background
|
|
await this.sandbox.runCommand({
|
|
cmd: 'sh',
|
|
args: ['-c', 'nohup npm run dev > /tmp/vite.log 2>&1 &'],
|
|
cwd: '/vercel/sandbox'
|
|
});
|
|
|
|
|
|
// Wait for Vite to be ready
|
|
await new Promise(resolve => setTimeout(resolve, 7000));
|
|
|
|
// Track initial files
|
|
this.existingFiles.add('src/App.jsx');
|
|
this.existingFiles.add('src/main.jsx');
|
|
this.existingFiles.add('src/index.css');
|
|
this.existingFiles.add('index.html');
|
|
this.existingFiles.add('package.json');
|
|
this.existingFiles.add('vite.config.js');
|
|
this.existingFiles.add('tailwind.config.js');
|
|
this.existingFiles.add('postcss.config.js');
|
|
}
|
|
|
|
async restartViteServer(): Promise<void> {
|
|
if (!this.sandbox) {
|
|
throw new Error('No active sandbox');
|
|
}
|
|
|
|
|
|
// Kill existing Vite process
|
|
await this.sandbox.runCommand({
|
|
cmd: 'sh',
|
|
args: ['-c', 'pkill -f vite || true'],
|
|
cwd: '/'
|
|
});
|
|
|
|
// Wait a moment
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
// Start Vite in background
|
|
await this.sandbox.runCommand({
|
|
cmd: 'sh',
|
|
args: ['-c', 'nohup npm run dev > /tmp/vite.log 2>&1 &'],
|
|
cwd: '/vercel/sandbox'
|
|
});
|
|
|
|
|
|
// Wait for Vite to be ready
|
|
await new Promise(resolve => setTimeout(resolve, 7000));
|
|
}
|
|
|
|
getSandboxUrl(): string | null {
|
|
return this.sandboxInfo?.url || null;
|
|
}
|
|
|
|
getSandboxInfo(): SandboxInfo | null {
|
|
return this.sandboxInfo;
|
|
}
|
|
|
|
async terminate(): Promise<void> {
|
|
if (this.sandbox) {
|
|
try {
|
|
await this.sandbox.stop();
|
|
} catch (e) {
|
|
console.error('Failed to terminate sandbox:', e);
|
|
}
|
|
this.sandbox = null;
|
|
this.sandboxInfo = null;
|
|
}
|
|
}
|
|
|
|
isAlive(): boolean {
|
|
return !!this.sandbox;
|
|
}
|
|
} |