From 5f6a6de751aff985064a99c8faefd7d7c2ab623d Mon Sep 17 00:00:00 2001 From: Jackson57279 Date: Fri, 16 Jan 2026 17:24:22 -0600 Subject: [PATCH 1/4] Added support for expo --- bun.lock | 15 +- convex/importData.ts | 18 +- convex/sandboxSessions.ts | 3 +- convex/schema.ts | 15 +- convex/usage.ts | 53 ++++ explanations/EXPO_INTEGRATION.md | 206 ++++++++++++++ package.json | 3 +- sandbox-templates/expo-android/e2b.Dockerfile | 56 ++++ sandbox-templates/expo-android/e2b.toml | 15 + .../expo-android/start_android.sh | 47 ++++ sandbox-templates/expo-full/e2b.Dockerfile | 23 ++ sandbox-templates/expo-full/e2b.toml | 15 + sandbox-templates/expo-web/e2b.Dockerfile | 20 ++ sandbox-templates/expo-web/e2b.toml | 15 + src/agents/code-agent.ts | 13 +- src/agents/eas-build.ts | 257 +++++++++++++++++ src/agents/expo-qr.ts | 93 +++++++ src/agents/sandbox-utils.ts | 21 +- src/agents/types.ts | 22 +- src/components/ExpoPreviewSelector.tsx | 150 ++++++++++ src/lib/frameworks.ts | 67 +++++ src/prompt.ts | 1 + src/prompts/expo.ts | 263 ++++++++++++++++++ src/prompts/framework-selector.ts | 30 +- 24 files changed, 1390 insertions(+), 31 deletions(-) create mode 100644 explanations/EXPO_INTEGRATION.md create mode 100644 sandbox-templates/expo-android/e2b.Dockerfile create mode 100644 sandbox-templates/expo-android/e2b.toml create mode 100644 sandbox-templates/expo-android/start_android.sh create mode 100644 sandbox-templates/expo-full/e2b.Dockerfile create mode 100644 sandbox-templates/expo-full/e2b.toml create mode 100644 sandbox-templates/expo-web/e2b.Dockerfile create mode 100644 sandbox-templates/expo-web/e2b.toml create mode 100644 src/agents/eas-build.ts create mode 100644 src/agents/expo-qr.ts create mode 100644 src/components/ExpoPreviewSelector.tsx create mode 100644 src/prompts/expo.ts diff --git a/bun.lock b/bun.lock index 36328ada..4377effb 100644 --- a/bun.lock +++ b/bun.lock @@ -66,7 +66,6 @@ "e2b": "^2.9.0", "embla-carousel-react": "^8.6.0", "eslint-config-next": "^16.1.1", - "exa-js": "^2.0.12", "firecrawl": "^4.10.0", "input-otp": "^1.4.2", "jest": "^30.2.0", @@ -76,6 +75,7 @@ "next-themes": "^0.4.6", "npkill": "^0.12.2", "prismjs": "^1.30.0", + "qrcode": "^1.5.4", "random-word-slugs": "^0.1.7", "react": "^19.2.3", "react-day-picker": "^9.13.0", @@ -101,6 +101,7 @@ "@tailwindcss/postcss": "^4.1.18", "@types/node": "^24.10.4", "@types/prismjs": "^1.26.5", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", @@ -1026,6 +1027,8 @@ "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -1350,8 +1353,6 @@ "crc": ["crc@4.3.2", "", { "peerDependencies": { "buffer": ">=6.0.3" }, "optionalPeers": ["buffer"] }, "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A=="], - "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -1536,8 +1537,6 @@ "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - "exa-js": ["exa-js@2.0.12", "", { "dependencies": { "cross-fetch": "~4.1.0", "dotenv": "~16.4.7", "openai": "^5.0.1", "zod": "^3.22.0", "zod-to-json-schema": "^3.20.0" } }, "sha512-56ZYm8FLKAh3JXCptr0vlG8f39CZxCl4QcPW9QR4TSKS60PU12pEfuQdf+6xGWwQp+doTgXguCqqzxtvgDTDKw=="], - "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "exit-x": ["exit-x@0.2.2", "", {}, "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ=="], @@ -2042,8 +2041,6 @@ "open-file-explorer": ["open-file-explorer@1.0.2", "", {}, "sha512-U4p+VW5uhtgK5W7qSsRhKioYAHCiTX9PiqV4ZtAFLMGfQ3QhppaEevk8k8+DSjM6rgc1yNIR2nttDuWfdNnnJQ=="], - "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], - "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="], "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], @@ -2732,10 +2729,6 @@ "eslint-plugin-react-hooks/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], - "exa-js/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], - - "exa-js/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], diff --git a/convex/importData.ts b/convex/importData.ts index 60f4fef7..d666c25a 100644 --- a/convex/importData.ts +++ b/convex/importData.ts @@ -16,7 +16,8 @@ export const importProject = internalMutation({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), // ISO date string updatedAt: v.string(), // ISO date string @@ -89,7 +90,8 @@ export const importFragment = internalMutation({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), @@ -130,7 +132,8 @@ export const importFragmentDraft = internalMutation({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), @@ -278,7 +281,8 @@ export const importProjectAction = action({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), @@ -320,7 +324,8 @@ export const importFragmentAction = action({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), @@ -343,7 +348,8 @@ export const importFragmentDraftAction = action({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), diff --git a/convex/sandboxSessions.ts b/convex/sandboxSessions.ts index 55632503..ff52059b 100644 --- a/convex/sandboxSessions.ts +++ b/convex/sandboxSessions.ts @@ -16,7 +16,8 @@ export const create = mutation({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), autoPauseTimeout: v.optional(v.number()), // Default 10 minutes }, diff --git a/convex/schema.ts b/convex/schema.ts index b0db7577..87dfd8d7 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -6,7 +6,15 @@ export const frameworkEnum = v.union( v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") +); + +export const expoPreviewModeEnum = v.union( + v.literal("web"), + v.literal("expo-go"), + v.literal("android-emulator"), + v.literal("eas-build") ); export const messageRoleEnum = v.union( @@ -115,6 +123,11 @@ export default defineSchema({ files: v.any(), metadata: v.optional(v.any()), framework: frameworkEnum, + expoPreviewMode: v.optional(expoPreviewModeEnum), + expoQrCodeUrl: v.optional(v.string()), + expoVncUrl: v.optional(v.string()), + expoEasBuildUrl: v.optional(v.string()), + expoApkUrl: v.optional(v.string()), createdAt: v.optional(v.number()), updatedAt: v.optional(v.number()), }) diff --git a/convex/usage.ts b/convex/usage.ts index aed54459..dc56aab8 100644 --- a/convex/usage.ts +++ b/convex/usage.ts @@ -9,6 +9,59 @@ const UNLIMITED_POINTS = Number.MAX_SAFE_INTEGER; const DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds const GENERATION_COST = 1; +// Expo-specific limits by tier +export const EXPO_LIMITS = { + free: { + webPreview: true, + expoGo: true, + androidEmulator: false, + easBuild: false, + maxBuildsPerDay: 5, + maxEmulatorMinutes: 0 + }, + pro: { + webPreview: true, + expoGo: true, + androidEmulator: true, + easBuild: true, + maxBuildsPerDay: 50, + maxEmulatorMinutes: 120 // 2 hours per day + }, + enterprise: { + webPreview: true, + expoGo: true, + androidEmulator: true, + easBuild: true, + maxBuildsPerDay: 500, + maxEmulatorMinutes: 600 // 10 hours per day + } +} as const; + +export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build'; +export type UserTier = 'free' | 'pro' | 'enterprise'; + +/** + * Check if user can use a specific Expo preview mode + */ +export function canUseExpoPreviewMode( + tier: UserTier, + mode: ExpoPreviewMode +): boolean { + const limits = EXPO_LIMITS[tier]; + switch (mode) { + case 'web': + return limits.webPreview; + case 'expo-go': + return limits.expoGo; + case 'android-emulator': + return limits.androidEmulator; + case 'eas-build': + return limits.easBuild; + default: + return false; + } +} + /** * Check and consume credits for a generation * Returns true if credits were successfully consumed, false if insufficient credits diff --git a/explanations/EXPO_INTEGRATION.md b/explanations/EXPO_INTEGRATION.md new file mode 100644 index 00000000..54a4118e --- /dev/null +++ b/explanations/EXPO_INTEGRATION.md @@ -0,0 +1,206 @@ +# Expo/React Native Integration + +ZapDev supports Expo/React Native for cross-platform mobile app development with multiple preview modes. + +## Overview + +Expo enables building iOS, Android, and web apps from a single codebase using React Native. ZapDev integrates Expo with 4 distinct preview modes to support different development and testing scenarios. + +## Preview Modes + +### 1. Web Preview (Free Tier) +- **Speed:** ~30 seconds +- **Description:** Uses `react-native-web` for fast browser-based preview +- **Limitations:** No native APIs (camera, location, haptics, etc.) +- **Best for:** Quick prototyping, UI development, web-compatible features + +### 2. Expo Go QR Code (Free Tier) +- **Speed:** ~1-2 minutes +- **Description:** Generate a QR code that users scan with the Expo Go app +- **Limitations:** Limited to Expo SDK modules, no custom native code +- **Best for:** Real device testing, sharing demos with stakeholders + +### 3. Android Emulator (Pro Tier) +- **Speed:** ~3-5 minutes +- **Description:** Full Android emulator running in E2B with VNC access +- **Limitations:** Requires Pro subscription, higher resource usage +- **Best for:** Full Android testing, GPU-dependent features, native APIs + +### 4. EAS Build (Pro Tier) +- **Speed:** ~5-15 minutes +- **Description:** Cloud builds via Expo Application Services +- **Output:** Installable APK (Android) or IPA (iOS) files +- **Best for:** Production releases, App Store/Play Store submissions + +## Framework Detection + +ZapDev automatically detects Expo projects from user prompts containing: +- "mobile app", "iOS", "Android" +- "React Native", "Expo" +- "cross-platform", "native app" +- "phone app" + +## AI Prompt Guidelines + +When generating Expo code, the AI follows these rules: + +1. **Components:** Use React Native components (View, Text, TouchableOpacity, etc.) +2. **Styling:** Use `StyleSheet.create()` - NO CSS files, NO className, NO Tailwind +3. **Imports:** `import { View, Text } from 'react-native'` +4. **Entry Point:** `App.tsx` as the root component +5. **Navigation:** Use `expo-router` for multi-screen apps + +### Example Component + +```tsx +import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; + +export default function App() { + return ( + + Hello Expo + + Press Me + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + }, + button: { + backgroundColor: '#007AFF', + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); +``` + +## Expo SDK Modules + +### Pre-installed (All Templates) +- `expo-status-bar` - Status bar control +- `expo-font` - Custom fonts +- `expo-linear-gradient` - Gradient backgrounds +- `expo-blur` - Blur effects + +### Available via `npx expo install` +- `expo-camera` - Camera access +- `expo-image-picker` - Photo library/camera capture +- `expo-location` - GPS/location +- `expo-haptics` - Haptic feedback +- `expo-notifications` - Push notifications +- `expo-file-system` - File operations +- `expo-av` - Audio/video playback +- `expo-sensors` - Accelerometer, gyroscope +- `expo-secure-store` - Secure storage +- `expo-sqlite` - Local database + +## Web Compatibility + +When using Web Preview mode, these components are **NOT available**: +- `expo-camera` +- `expo-location` +- `expo-haptics` +- `expo-sensors` +- `expo-notifications` (limited) +- `expo-secure-store` + +### Web Alternatives +- **Camera:** Use `` +- **Location:** Use `navigator.geolocation` +- **Storage:** Use AsyncStorage or localStorage + +## E2B Sandbox Templates + +### zapdev-expo-web +- Base: `node:21-slim` +- Pre-installed: react-native-web, @expo/metro-runtime +- Port: 8081 (Metro bundler) +- Command: `npx expo start --web` + +### zapdev-expo-full +- Base: `node:21-slim` +- Pre-installed: All Expo SDK modules +- Port: 8081 (with tunnel for Expo Go) +- Command: `npx expo start --tunnel` + +### zapdev-expo-android +- Base: `ubuntu:22.04` +- Includes: Android SDK, emulator, VNC server +- Ports: 5900 (VNC), 8081 (Metro), 5555 (ADB) +- Resources: 4 vCPU, 8GB RAM + +## Subscription Tiers + +| Feature | Free | Pro | Enterprise | +|---------|------|-----|------------| +| Web Preview | ✅ | ✅ | ✅ | +| Expo Go (QR) | ✅ | ✅ | ✅ | +| Android Emulator | ❌ | ✅ | ✅ | +| EAS Build | ❌ | ✅ | ✅ | +| Max Builds/Day | 5 | 50 | 500 | +| Emulator Minutes/Day | 0 | 120 | 600 | + +## Environment Variables + +For EAS Build support, add to `.env`: +```bash +EXPO_ACCESS_TOKEN=your_expo_token_here +``` + +Get your token from: https://expo.dev/settings/access-tokens + +## Troubleshooting + +### Web Preview Shows Blank Screen +- Ensure you're using web-compatible components +- Check console for `react-native-web` errors +- Avoid native-only modules + +### Expo Go QR Not Working +- Verify tunnel is running (`--tunnel` flag) +- Check network connectivity +- Ensure Expo Go app is up to date + +### Android Emulator Not Starting +- Requires Pro tier subscription +- VNC may take 30-60s to initialize +- Check if KVM is available on E2B + +### EAS Build Failing +- Verify `EXPO_ACCESS_TOKEN` is set +- Check `eas.json` configuration +- Ensure `app.json` has required fields (slug, version) + +## Example Prompts + +1. "Build a mobile todo app for iOS and Android" +2. "Create a React Native camera app" +3. "Make a cross-platform fitness tracker" +4. "Build an Expo app with location tracking" +5. "Create a mobile social media feed" + +## Related Documentation + +- [Expo Official Docs](https://docs.expo.dev) +- [React Native Docs](https://reactnative.dev) +- [E2B Expo Template](https://e2b.dev/docs/template/examples/expo) diff --git a/package.json b/package.json index 97ca952f..e011d283 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "e2b": "^2.9.0", "embla-carousel-react": "^8.6.0", "eslint-config-next": "^16.1.1", - "firecrawl": "^4.10.0", "input-otp": "^1.4.2", "jest": "^30.2.0", @@ -83,6 +82,7 @@ "next-themes": "^0.4.6", "npkill": "^0.12.2", "prismjs": "^1.30.0", + "qrcode": "^1.5.4", "random-word-slugs": "^0.1.7", "react": "^19.2.3", "react-day-picker": "^9.13.0", @@ -108,6 +108,7 @@ "@tailwindcss/postcss": "^4.1.18", "@types/node": "^24.10.4", "@types/prismjs": "^1.26.5", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", diff --git a/sandbox-templates/expo-android/e2b.Dockerfile b/sandbox-templates/expo-android/e2b.Dockerfile new file mode 100644 index 00000000..06748330 --- /dev/null +++ b/sandbox-templates/expo-android/e2b.Dockerfile @@ -0,0 +1,56 @@ +# Expo Android Emulator Template with VNC +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Install base dependencies +RUN apt-get update && apt-get install -y \ + curl wget git unzip openjdk-17-jdk \ + x11vnc xvfb fluxbox \ + qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils \ + supervisor \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install Node.js 21 +RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - \ + && apt-get install -y nodejs + +# Set up Android SDK +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator + +RUN mkdir -p $ANDROID_HOME/cmdline-tools \ + && cd $ANDROID_HOME/cmdline-tools \ + && wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdline-tools.zip \ + && unzip -q cmdline-tools.zip \ + && mv cmdline-tools latest \ + && rm cmdline-tools.zip + +# Accept licenses and install Android SDK components +RUN yes | sdkmanager --licenses > /dev/null 2>&1 || true +RUN sdkmanager "platform-tools" "platforms;android-34" "emulator" "system-images;android-34;google_apis;x86_64" + +# Create AVD (Android Virtual Device) +RUN echo no | avdmanager create avd -n expo_emulator -k "system-images;android-34;google_apis;x86_64" --force + +WORKDIR /home/user + +# Create Expo project +RUN npx create-expo-app@latest . --template blank-typescript --yes + +# Install dependencies +RUN npm install react-dom react-native-web @expo/metro-runtime +RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics + +# Install global tools +RUN npm install -g @expo/cli eas-cli + +# Copy start script +COPY start_android.sh /start_android.sh +RUN chmod +x /start_android.sh + +# Expose ports: VNC(5900), ADB(5555), Metro(8081), Expo(19000-19002) +EXPOSE 5900 5555 8081 19000 19001 19002 + +CMD ["/start_android.sh"] diff --git a/sandbox-templates/expo-android/e2b.toml b/sandbox-templates/expo-android/e2b.toml new file mode 100644 index 00000000..73ffbe44 --- /dev/null +++ b/sandbox-templates/expo-android/e2b.toml @@ -0,0 +1,15 @@ +# E2B Sandbox Template Configuration for Expo Android Emulator + +# Template name used when creating sandboxes +template_id = "zapdev-expo-android" + +# Dockerfile to build the template +dockerfile = "e2b.Dockerfile" + +# Start command (runs when sandbox starts) +start_cmd = "/start_android.sh" + +# Template resource configuration (higher specs for emulator) +[resources] +cpu_count = 4 +memory_mb = 8192 diff --git a/sandbox-templates/expo-android/start_android.sh b/sandbox-templates/expo-android/start_android.sh new file mode 100644 index 00000000..acdde1f0 --- /dev/null +++ b/sandbox-templates/expo-android/start_android.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Start virtual display +echo "[INFO] Starting virtual display..." +Xvfb :99 -screen 0 1280x720x24 & +export DISPLAY=:99 + +# Wait for Xvfb to start +sleep 2 + +# Start window manager +echo "[INFO] Starting window manager..." +fluxbox & + +# Start VNC server +echo "[INFO] Starting VNC server on port 5900..." +x11vnc -display :99 -forever -shared -rfbport 5900 -nopw & + +# Wait for display services +sleep 2 + +# Start Android emulator +echo "[INFO] Starting Android emulator..." +$ANDROID_HOME/emulator/emulator -avd expo_emulator \ + -no-audio \ + -no-boot-anim \ + -gpu swiftshader_indirect \ + -no-snapshot \ + -memory 2048 \ + -cores 2 & + +# Wait for emulator to boot +echo "[INFO] Waiting for emulator to boot..." +adb wait-for-device + +# Wait for boot completion +echo "[INFO] Waiting for boot completion..." +while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do + sleep 2 +done + +echo "[INFO] Emulator ready!" + +# Start Expo Metro bundler with Android +cd /home/user +echo "[INFO] Starting Expo development server..." +npx expo start --android --port 8081 --host 0.0.0.0 diff --git a/sandbox-templates/expo-full/e2b.Dockerfile b/sandbox-templates/expo-full/e2b.Dockerfile new file mode 100644 index 00000000..4c42bb0e --- /dev/null +++ b/sandbox-templates/expo-full/e2b.Dockerfile @@ -0,0 +1,23 @@ +# Expo Full Template (Web + Expo Go support with tunnel) +FROM node:21-slim + +RUN apt-get update && apt-get install -y curl git qrencode && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /home/user + +# Create Expo app with TypeScript blank template +RUN npx create-expo-app@latest . --template blank-typescript --yes + +# Install web dependencies +RUN npm install react-dom react-native-web @expo/metro-runtime + +# Install common Expo SDK modules +RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics + +# Install Expo CLI globally for tunnel support +RUN npm install -g @expo/cli eas-cli + +WORKDIR /home/user + +# Start Metro bundler with tunnel for Expo Go access +CMD ["npx", "expo", "start", "--port", "8081", "--host", "0.0.0.0", "--tunnel"] diff --git a/sandbox-templates/expo-full/e2b.toml b/sandbox-templates/expo-full/e2b.toml new file mode 100644 index 00000000..51a2bc15 --- /dev/null +++ b/sandbox-templates/expo-full/e2b.toml @@ -0,0 +1,15 @@ +# E2B Sandbox Template Configuration for Expo Full (Web + Expo Go) + +# Template name used when creating sandboxes +template_id = "zapdev-expo-full" + +# Dockerfile to build the template +dockerfile = "e2b.Dockerfile" + +# Start command (runs when sandbox starts) +start_cmd = "npx expo start --port 8081 --host 0.0.0.0 --tunnel" + +# Template resource configuration +[resources] +cpu_count = 2 +memory_mb = 2048 diff --git a/sandbox-templates/expo-web/e2b.Dockerfile b/sandbox-templates/expo-web/e2b.Dockerfile new file mode 100644 index 00000000..ef019f10 --- /dev/null +++ b/sandbox-templates/expo-web/e2b.Dockerfile @@ -0,0 +1,20 @@ +# Expo Web Preview Template +FROM node:21-slim + +RUN apt-get update && apt-get install -y curl git && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /home/user + +# Create Expo app with TypeScript blank template +RUN npx create-expo-app@latest . --template blank-typescript --yes + +# Install web dependencies +RUN npm install react-dom react-native-web @expo/metro-runtime + +# Install common Expo SDK modules +RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar + +WORKDIR /home/user + +# Start Metro bundler for web on port 8081 +CMD ["npx", "expo", "start", "--web", "--port", "8081", "--host", "0.0.0.0"] diff --git a/sandbox-templates/expo-web/e2b.toml b/sandbox-templates/expo-web/e2b.toml new file mode 100644 index 00000000..6493c7d0 --- /dev/null +++ b/sandbox-templates/expo-web/e2b.toml @@ -0,0 +1,15 @@ +# E2B Sandbox Template Configuration for Expo Web + +# Template name used when creating sandboxes +template_id = "zapdev-expo-web" + +# Dockerfile to build the template +dockerfile = "e2b.Dockerfile" + +# Start command (runs when sandbox starts) +start_cmd = "npx expo start --web --port 8081 --host 0.0.0.0" + +# Template resource configuration +[resources] +cpu_count = 2 +memory_mb = 2048 diff --git a/src/agents/code-agent.ts b/src/agents/code-agent.ts index 99408762..405169d5 100644 --- a/src/agents/code-agent.ts +++ b/src/agents/code-agent.ts @@ -12,6 +12,7 @@ import { type AgentState, type AgentRunInput, type ModelId, + type ExpoPreviewMode, MODEL_CONFIGS, selectModelForTask, frameworkToConvexEnum, @@ -37,6 +38,9 @@ import { REACT_PROMPT, VUE_PROMPT, SVELTE_PROMPT, + EXPO_PROMPT, + EXPO_WEB_PROMPT, + EXPO_NATIVE_PROMPT, } from "@/prompt"; import { sanitizeTextForDatabase } from "@/lib/utils"; import { filterAIGeneratedFiles } from "@/lib/filter-ai-files"; @@ -111,7 +115,7 @@ const extractSummaryText = (value: string): string => { return trimmed; }; -const getFrameworkPrompt = (framework: Framework): string => { +const getFrameworkPrompt = (framework: Framework, expoPreviewMode?: ExpoPreviewMode): string => { switch (framework) { case "nextjs": return NEXTJS_PROMPT; @@ -123,6 +127,11 @@ const getFrameworkPrompt = (framework: Framework): string => { return VUE_PROMPT; case "svelte": return SVELTE_PROMPT; + case "expo": + // Use appropriate prompt based on preview mode + if (expoPreviewMode === "web") return EXPO_WEB_PROMPT; + if (expoPreviewMode === "android-emulator" || expoPreviewMode === "expo-go") return EXPO_NATIVE_PROMPT; + return EXPO_PROMPT; default: return NEXTJS_PROMPT; } @@ -157,7 +166,7 @@ async function detectFramework(prompt: string): Promise { const detectedFramework = text.trim().toLowerCase(); if ( - ["nextjs", "angular", "react", "vue", "svelte"].includes(detectedFramework) + ["nextjs", "angular", "react", "vue", "svelte", "expo"].includes(detectedFramework) ) { return detectedFramework as Framework; } diff --git a/src/agents/eas-build.ts b/src/agents/eas-build.ts new file mode 100644 index 00000000..b8f15b6c --- /dev/null +++ b/src/agents/eas-build.ts @@ -0,0 +1,257 @@ +import { Sandbox } from "@e2b/code-interpreter"; +import { getSandbox, runCodeCommand } from "./sandbox-utils"; + +export interface EASBuildConfig { + platform: 'android' | 'ios' | 'all'; + profile: 'development' | 'preview' | 'production'; + expoToken?: string; +} + +export interface EASBuildResult { + buildId: string; + buildUrl: string; + platform: string; + status: 'pending' | 'in-queue' | 'in-progress' | 'finished' | 'errored' | 'canceled'; +} + +export interface EASBuildStatus { + status: 'pending' | 'in-queue' | 'in-progress' | 'finished' | 'errored' | 'canceled'; + downloadUrl?: string; + artifacts?: { + buildUrl?: string; + applicationArchiveUrl?: string; + }; + error?: string; +} + +/** + * Initialize EAS in a sandbox (creates eas.json if it doesn't exist) + */ +export async function initializeEAS(sandbox: Sandbox): Promise { + console.log('[INFO] Initializing EAS configuration...'); + + // Check if eas.json exists + const checkResult = await runCodeCommand(sandbox, 'test -f eas.json && echo "exists"'); + + if (!checkResult.stdout.includes('exists')) { + // Create default eas.json configuration + const easConfig = { + cli: { + version: ">= 13.0.0" + }, + build: { + development: { + developmentClient: true, + distribution: "internal" + }, + preview: { + distribution: "internal", + android: { + buildType: "apk" + } + }, + production: { + autoIncrement: true + } + }, + submit: { + production: {} + } + }; + + // Write eas.json + await sandbox.files.write('/home/user/eas.json', JSON.stringify(easConfig, null, 2)); + console.log('[INFO] Created eas.json configuration'); + } + + // Ensure app.json has required fields for EAS + try { + const appJsonContent = await sandbox.files.read('/home/user/app.json'); + if (typeof appJsonContent === 'string') { + const appJson = JSON.parse(appJsonContent); + + // Ensure required fields exist + if (!appJson.expo) appJson.expo = {}; + if (!appJson.expo.slug) appJson.expo.slug = 'zapdev-app'; + if (!appJson.expo.name) appJson.expo.name = 'ZapDev App'; + if (!appJson.expo.version) appJson.expo.version = '1.0.0'; + + // Add EAS project ID placeholder if not present + if (!appJson.expo.extra) appJson.expo.extra = {}; + if (!appJson.expo.extra.eas) appJson.expo.extra.eas = {}; + + await sandbox.files.write('/home/user/app.json', JSON.stringify(appJson, null, 2)); + console.log('[INFO] Updated app.json for EAS compatibility'); + } + } catch (error) { + console.warn('[WARN] Could not update app.json:', error); + } +} + +/** + * Trigger an EAS Build + */ +export async function triggerEASBuild( + sandboxId: string, + config: EASBuildConfig +): Promise { + const sandbox = await getSandbox(sandboxId); + const expoToken = config.expoToken || process.env.EXPO_ACCESS_TOKEN; + + if (!expoToken) { + throw new Error('EXPO_ACCESS_TOKEN is required for EAS builds. Set it in environment variables.'); + } + + // Initialize EAS if needed + await initializeEAS(sandbox); + + console.log(`[INFO] Triggering EAS build for platform: ${config.platform}, profile: ${config.profile}`); + + // Build the command with proper token handling + const buildCommand = `EXPO_TOKEN="${expoToken}" npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`; + + const result = await runCodeCommand(sandbox, buildCommand); + + if (result.exitCode !== 0) { + console.error('[ERROR] EAS build command failed:', result.stderr); + throw new Error(`EAS build failed: ${result.stderr || result.stdout}`); + } + + try { + // Parse the JSON output from EAS CLI + const output = result.stdout.trim(); + const jsonMatch = output.match(/\[[\s\S]*\]|\{[\s\S]*\}/); + + if (!jsonMatch) { + throw new Error('Could not parse EAS build output'); + } + + const buildData = JSON.parse(jsonMatch[0]); + const build = Array.isArray(buildData) ? buildData[0] : buildData; + + return { + buildId: build.id, + buildUrl: `https://expo.dev/accounts/${build.accountName || 'user'}/projects/${build.projectId || 'project'}/builds/${build.id}`, + platform: build.platform || config.platform, + status: build.status || 'pending' + }; + } catch (parseError) { + console.error('[ERROR] Failed to parse EAS build output:', result.stdout); + throw new Error(`Failed to parse EAS build response: ${parseError instanceof Error ? parseError.message : String(parseError)}`); + } +} + +/** + * Check the status of an EAS build + */ +export async function checkEASBuildStatus( + buildId: string, + expoToken?: string +): Promise { + const token = expoToken || process.env.EXPO_ACCESS_TOKEN; + + if (!token) { + throw new Error('EXPO_ACCESS_TOKEN is required to check build status'); + } + + try { + const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch build status: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + return { + status: data.status, + downloadUrl: data.artifacts?.buildUrl || data.artifacts?.applicationArchiveUrl, + artifacts: data.artifacts, + error: data.error + }; + } catch (error) { + console.error('[ERROR] Failed to check EAS build status:', error); + throw new Error(`Failed to check build status: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Poll for EAS build completion + */ +export async function waitForEASBuild( + buildId: string, + expoToken?: string, + maxWaitMs: number = 15 * 60 * 1000, // 15 minutes default + pollIntervalMs: number = 10000 // 10 seconds +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + const status = await checkEASBuildStatus(buildId, expoToken); + + if (status.status === 'finished') { + console.log(`[INFO] EAS build ${buildId} completed successfully`); + return status; + } + + if (status.status === 'errored' || status.status === 'canceled') { + console.error(`[ERROR] EAS build ${buildId} failed with status: ${status.status}`); + throw new Error(`EAS build failed: ${status.error || status.status}`); + } + + console.log(`[DEBUG] EAS build ${buildId} status: ${status.status}`); + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error(`EAS build timed out after ${maxWaitMs / 1000} seconds`); +} + +/** + * Get the download URL for a completed build + */ +export async function getEASBuildDownloadUrl( + buildId: string, + expoToken?: string +): Promise { + const status = await checkEASBuildStatus(buildId, expoToken); + + if (status.status !== 'finished') { + return null; + } + + return status.downloadUrl || null; +} + +/** + * Cancel an in-progress EAS build + */ +export async function cancelEASBuild( + buildId: string, + expoToken?: string +): Promise { + const token = expoToken || process.env.EXPO_ACCESS_TOKEN; + + if (!token) { + throw new Error('EXPO_ACCESS_TOKEN is required to cancel a build'); + } + + try { + const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}/cancel`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + }); + + return response.ok; + } catch (error) { + console.error('[ERROR] Failed to cancel EAS build:', error); + return false; + } +} diff --git a/src/agents/expo-qr.ts b/src/agents/expo-qr.ts new file mode 100644 index 00000000..8b7d3ccb --- /dev/null +++ b/src/agents/expo-qr.ts @@ -0,0 +1,93 @@ +import QRCode from 'qrcode'; + +/** + * Generate a QR code for Expo Go app to scan + * @param sandboxUrl The sandbox URL (e.g., https://8081-abc123.e2b.dev) + * @returns Base64 data URL of the QR code image + */ +export async function generateExpoGoQR(sandboxUrl: string): Promise { + try { + // Expo Go expects exp:// protocol URLs + const url = new URL(sandboxUrl); + const expoUrl = `exp://${url.host}`; + + // Generate QR code as data URL + const qrDataUrl = await QRCode.toDataURL(expoUrl, { + width: 400, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + }, + errorCorrectionLevel: 'M' + }); + + console.log(`[INFO] Generated Expo Go QR code for: ${expoUrl}`); + return qrDataUrl; + } catch (error) { + console.error('[ERROR] Failed to generate Expo Go QR code:', error); + throw new Error(`Failed to generate QR code: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Get the official Expo QR code service URL + * This uses Expo's hosted service to generate QR codes + * @param sandboxUrl The sandbox URL + * @returns URL to Expo's QR code service + */ +export function getExpoOfficialQRUrl(sandboxUrl: string): string { + const encodedUrl = encodeURIComponent(sandboxUrl); + return `https://qr.expo.dev/development-client?url=${encodedUrl}`; +} + +/** + * Generate QR code for EAS Update (for production apps) + * @param projectId Expo project ID + * @param channel Update channel (e.g., 'preview', 'production') + * @param runtimeVersion The runtime version + * @returns URL to Expo's QR code service for the update + */ +export function getEASUpdateQRUrl( + projectId: string, + channel: string = 'preview', + runtimeVersion?: string +): string { + let url = `https://qr.expo.dev/eas-update?projectId=${projectId}&channel=${channel}`; + if (runtimeVersion) { + url += `&runtimeVersion=${encodeURIComponent(runtimeVersion)}`; + } + return url; +} + +/** + * Generate a deep link URL for Expo Go + * @param sandboxUrl The sandbox URL + * @returns Deep link URL that opens in Expo Go + */ +export function getExpoGoDeepLink(sandboxUrl: string): string { + const url = new URL(sandboxUrl); + return `exp://${url.host}`; +} + +/** + * Check if a URL is accessible (for Expo Go tunnel) + * @param url The URL to check + * @returns Whether the URL is accessible + */ +export async function checkUrlAccessible(url: string): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal + }); + + clearTimeout(timeoutId); + return response.ok; + } catch { + return false; + } +} diff --git a/src/agents/sandbox-utils.ts b/src/agents/sandbox-utils.ts index 5d36b4fe..b7979805 100644 --- a/src/agents/sandbox-utils.ts +++ b/src/agents/sandbox-utils.ts @@ -1,5 +1,5 @@ import { Sandbox } from "@e2b/code-interpreter"; -import { SANDBOX_TIMEOUT, type Framework } from "./types"; +import { SANDBOX_TIMEOUT, type Framework, type ExpoPreviewMode } from "./types"; const SANDBOX_CACHE = new Map(); const PROJECT_SANDBOX_MAP = new Map(); @@ -307,35 +307,47 @@ export async function readFileFast( } } -export function getE2BTemplate(framework: Framework): string { +export function getE2BTemplate(framework: Framework, expoPreviewMode?: ExpoPreviewMode): string { switch (framework) { case "nextjs": return "zapdev"; case "angular": return "zapdev-angular"; case "react": return "zapdev-react"; case "vue": return "zapdev-vue"; case "svelte": return "zapdev-svelte"; + case "expo": + if (expoPreviewMode === "android-emulator") return "zapdev-expo-android"; + if (expoPreviewMode === "expo-go") return "zapdev-expo-full"; + return "zapdev-expo-web"; // Default to web preview (fastest) default: return "zapdev"; } } -export function getFrameworkPort(framework: Framework): number { +export function getFrameworkPort(framework: Framework, expoPreviewMode?: ExpoPreviewMode): number { switch (framework) { case "nextjs": return 3000; case "angular": return 4200; case "react": case "vue": case "svelte": return 5173; + case "expo": + if (expoPreviewMode === "android-emulator") return 5900; // VNC port + return 8081; // Metro bundler port default: return 3000; } } -export function getDevServerCommand(framework: Framework): string { +export function getDevServerCommand(framework: Framework, expoPreviewMode?: ExpoPreviewMode): string { switch (framework) { case "nextjs": return "npm run dev"; case "angular": return "npm run start -- --host 0.0.0.0 --port 4200"; case "react": case "vue": case "svelte": return "npm run dev -- --host 0.0.0.0 --port 5173"; + case "expo": + if (expoPreviewMode === "web") return "npx expo start --web --port 8081 --host 0.0.0.0"; + if (expoPreviewMode === "expo-go") return "npx expo start --tunnel --port 8081"; + if (expoPreviewMode === "android-emulator") return "/start_android.sh"; + return "npx expo start --web --port 8081 --host 0.0.0.0"; default: return "npm run dev"; } } @@ -408,6 +420,7 @@ export const getFindCommand = (framework: Framework): string => { const ignorePatterns = ["node_modules", ".git", "dist", "build"]; if (framework === "nextjs") ignorePatterns.push(".next"); if (framework === "svelte") ignorePatterns.push(".svelte-kit"); + if (framework === "expo") ignorePatterns.push(".expo"); return `find /home/user -type f -not -path '*/${ignorePatterns.join('/* -not -path */')}/*' 2>/dev/null`; }; diff --git a/src/agents/types.ts b/src/agents/types.ts index aabe3f33..eee88c59 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -1,6 +1,8 @@ export const SANDBOX_TIMEOUT = 60_000 * 60; -export type Framework = "nextjs" | "angular" | "react" | "vue" | "svelte"; +export type Framework = "nextjs" | "angular" | "react" | "vue" | "svelte" | "expo"; + +export type ExpoPreviewMode = "web" | "expo-go" | "android-emulator" | "eas-build"; export interface AgentState { summary: string; @@ -9,6 +11,14 @@ export interface AgentState { summaryRetryCount: number; } +export interface ExpoAgentState extends AgentState { + previewMode: ExpoPreviewMode; + qrCodeUrl?: string; + vncUrl?: string; + easBuildUrl?: string; + apkDownloadUrl?: string; +} + export interface AgentRunInput { projectId: string; value: string; @@ -23,6 +33,11 @@ export interface AgentRunResult { summary: string; sandboxId: string; framework: Framework; + expoPreviewMode?: ExpoPreviewMode; + expoQrCodeUrl?: string; + expoVncUrl?: string; + expoEasBuildUrl?: string; + expoApkUrl?: string; } export const MODEL_CONFIGS = { @@ -145,16 +160,17 @@ export function selectModelForTask( export function frameworkToConvexEnum( framework: Framework -): "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" { +): "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" | "EXPO" { const mapping: Record< Framework, - "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" + "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" | "EXPO" > = { nextjs: "NEXTJS", angular: "ANGULAR", react: "REACT", vue: "VUE", svelte: "SVELTE", + expo: "EXPO", }; return mapping[framework]; } diff --git a/src/components/ExpoPreviewSelector.tsx b/src/components/ExpoPreviewSelector.tsx new file mode 100644 index 00000000..dbb6a24a --- /dev/null +++ b/src/components/ExpoPreviewSelector.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build'; +export type UserTier = 'free' | 'pro' | 'enterprise'; + +interface PreviewOption { + mode: ExpoPreviewMode; + title: string; + description: string; + badge?: string; + buildTime: string; + tier: UserTier; + icon: string; +} + +const PREVIEW_OPTIONS: PreviewOption[] = [ + { + mode: 'web', + title: 'Web Preview', + description: 'Fastest preview using react-native-web', + buildTime: '~30 seconds', + tier: 'free', + icon: '🌐' + }, + { + mode: 'expo-go', + title: 'Expo Go (QR Code)', + description: 'Test on real device via Expo Go app', + buildTime: '~1-2 minutes', + tier: 'free', + icon: '📱' + }, + { + mode: 'android-emulator', + title: 'Android Emulator', + description: 'Full Android emulator with VNC access', + badge: 'Pro', + buildTime: '~3-5 minutes', + tier: 'pro', + icon: '🤖' + }, + { + mode: 'eas-build', + title: 'EAS Build (Production)', + description: 'Cloud builds for App Store/Play Store', + badge: 'Pro', + buildTime: '~5-15 minutes', + tier: 'pro', + icon: '🚀' + } +]; + +interface ExpoPreviewSelectorProps { + onSelect: (mode: ExpoPreviewMode) => void; + userTier?: UserTier; + selectedMode?: ExpoPreviewMode; + className?: string; +} + +export function ExpoPreviewSelector({ + onSelect, + userTier = 'free', + selectedMode, + className +}: ExpoPreviewSelectorProps) { + const [selected, setSelected] = useState(selectedMode ?? 'web'); + + const handleSelect = (mode: ExpoPreviewMode) => { + const option = PREVIEW_OPTIONS.find(o => o.mode === mode); + if (!option) return; + + const tierOrder: Record = { free: 0, pro: 1, enterprise: 2 }; + const isLocked = tierOrder[userTier] < tierOrder[option.tier]; + + if (!isLocked) { + setSelected(mode); + onSelect(mode); + } + }; + + return ( +
+ {PREVIEW_OPTIONS.map((option) => { + const tierOrder: Record = { free: 0, pro: 1, enterprise: 2 }; + const isLocked = tierOrder[userTier] < tierOrder[option.tier]; + const isSelected = selected === option.mode; + + return ( + handleSelect(option.mode)} + > + +
+
+ {option.icon} +

{option.title}

+
+
+ {option.badge && ( + + {option.badge} + + )} + {isLocked && ( + + 🔒 + + )} +
+
+

+ {option.description} +

+

+ Build time: {option.buildTime} +

+
+
+ ); + })} +
+ ); +} + +export function ExpoPreviewInfo({ mode }: { mode: ExpoPreviewMode }) { + const option = PREVIEW_OPTIONS.find(o => o.mode === mode); + if (!option) return null; + + return ( +
+ {option.icon} + {option.title} + ({option.buildTime}) +
+ ); +} + +export { PREVIEW_OPTIONS }; diff --git a/src/lib/frameworks.ts b/src/lib/frameworks.ts index e26259f0..7cb5c022 100644 --- a/src/lib/frameworks.ts +++ b/src/lib/frameworks.ts @@ -341,6 +341,73 @@ export const frameworks: Record = { 'SSG', 'production React' ] + }, + expo: { + slug: 'expo', + name: 'Expo', + title: 'Cross-Platform Mobile Development with Expo & React Native', + description: 'Expo is the easiest way to build iOS, Android, and web apps from a single codebase using React Native. Create production-ready mobile applications with our AI-powered development tools.', + metaDescription: 'Create mobile apps with Expo and React Native using AI. Multiple preview modes: web, Expo Go, Android emulator, and EAS Build for production iOS/Android apps.', + features: [ + 'Cross-Platform (iOS/Android/Web)', + 'Hot Reload & Fast Refresh', + 'Expo SDK Modules', + 'Multiple Preview Modes', + 'EAS Build Integration', + 'Over-the-Air Updates', + 'TypeScript Support', + 'expo-router Navigation' + ], + useCases: [ + 'Mobile-First Applications', + 'Social Media Apps', + 'E-commerce Mobile Apps', + 'Fitness & Health Trackers', + 'Photo & Video Apps', + 'Location-Based Services', + 'Progressive Web Apps' + ], + advantages: [ + 'One Codebase, Three Platforms', + 'Rich Native Module Ecosystem', + 'Fast Development Cycle', + 'Real Device Testing (Expo Go)', + 'Cloud Builds (No Xcode/Android Studio)', + 'Strong Community Support' + ], + icon: '📱', + color: '#000020', + popularity: 85, + ecosystem: [ + { + name: 'Expo Go', + description: 'Instant preview on real devices', + url: '/frameworks/expo/expo-go' + }, + { + name: 'EAS Build', + description: 'Cloud-based iOS/Android builds', + url: '/frameworks/expo/eas-build' + }, + { + name: 'expo-router', + description: 'File-based navigation system', + url: '/frameworks/expo/router' + } + ], + relatedFrameworks: ['react', 'nextjs'], + keywords: [ + 'Expo development', + 'React Native', + 'cross-platform mobile', + 'iOS development', + 'Android development', + 'mobile app framework', + 'Expo SDK', + 'React Native components', + 'EAS Build', + 'mobile development' + ] } }; diff --git a/src/prompt.ts b/src/prompt.ts index b3dd914a..c9b8e467 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -4,5 +4,6 @@ export { ANGULAR_PROMPT } from "./prompts/angular"; export { REACT_PROMPT } from "./prompts/react"; export { VUE_PROMPT } from "./prompts/vue"; export { SVELTE_PROMPT } from "./prompts/svelte"; +export { EXPO_PROMPT, EXPO_WEB_PROMPT, EXPO_NATIVE_PROMPT } from "./prompts/expo"; export { FRAMEWORK_SELECTOR_PROMPT } from "./prompts/framework-selector"; export { NEXTJS_PROMPT as PROMPT } from "./prompts/nextjs"; diff --git a/src/prompts/expo.ts b/src/prompts/expo.ts new file mode 100644 index 00000000..2483cdf1 --- /dev/null +++ b/src/prompts/expo.ts @@ -0,0 +1,263 @@ +import { SHARED_RULES } from "./shared"; + +export const EXPO_SHARED_RULES = ` +Environment: +- Writable file system via createOrUpdateFiles +- Command execution via terminal (use "npm install --yes" or "npx expo install ") +- Read files via readFiles +- Do not modify package.json or lock files directly — install packages using the terminal only +- All files are under /home/user +- Entry point is App.tsx (root component) + +File Safety Rules: +- All CREATE OR UPDATE file paths must be relative (e.g., "App.tsx", "components/Button.tsx") +- NEVER use absolute paths like "/home/user/..." or "/home/user/app/..." +- NEVER include "/home/user" in any file path — this will cause critical errors +- When using readFiles or accessing the file system, you MUST use the actual path (e.g. "/home/user/components/Button.tsx") + +Runtime Execution: +- Development servers are not started manually in this environment +- The Metro bundler is already running +- Use validation commands like "npx expo export:web" to verify your work +- Short-lived commands for type-checking and builds are allowed as needed for testing + +Error Prevention & Code Quality (CRITICAL): +1. MANDATORY Validation Before Completion: + - Run: npx tsc --noEmit (for type checking) + - Fix ANY and ALL TypeScript errors immediately + - Only output after validation passes with no errors + +2. Handle All Errors: Every function must include proper error handling +3. Type Safety: Use TypeScript properly with explicit types + +Instructions: +1. Use React Native components exclusively (View, Text, TouchableOpacity, etc.) +2. Use StyleSheet.create() for ALL styling — NO CSS files, NO className +3. Use Expo SDK modules for native functionality +4. Break complex UIs into multiple components +5. Use TypeScript with proper types +6. You MUST use the createOrUpdateFiles tool to make all file changes +7. You MUST use the terminal tool to install any packages (npx expo install ) +8. Do not print code inline or wrap code in backticks + +Final output (MANDATORY): +After ALL tool calls are complete and the task is finished, you MUST output: + + +A short, high-level summary of what was created or changed. + +`; + +export const EXPO_PROMPT = ` +You are a senior React Native engineer using Expo in a sandboxed environment. + +${EXPO_SHARED_RULES} + +Environment: +- Framework: Expo SDK 52+ with React Native 0.76+ +- Entry file: App.tsx (root component) +- Styling: StyleSheet API (React Native styles) +- Navigation: expo-router (file-based routing) or React Navigation +- Dev port: 8081 (Metro bundler) + +Critical Rules: +1. Use React Native components: View, Text, TouchableOpacity, ScrollView, FlatList, Image, TextInput, etc. +2. Styling MUST use StyleSheet.create() — NO CSS files, NO className, NO Tailwind +3. Import from 'react-native': \`import { View, Text, StyleSheet } from 'react-native'\` +4. Use Expo SDK modules: expo-camera, expo-location, expo-font, expo-image-picker, etc. +5. "use client" is NOT needed (React Native doesn't use this directive) +6. File structure: App.tsx as entry, components/ for reusable components +7. For multi-screen apps: Use expo-router with app/ directory structure + +Styling Example: +\`\`\`tsx +import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; + +export default function App() { + return ( + + Hello Expo + console.log('Pressed')}> + Press Me + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + }, + button: { + backgroundColor: '#007AFF', + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); +\`\`\` + +Expo SDK Modules (pre-installed): +- expo-status-bar (status bar control) +- expo-font (custom fonts) +- expo-linear-gradient (gradient backgrounds) +- expo-blur (blur effects) + +Expo SDK Modules (install with npx expo install): +- expo-camera (camera access) +- expo-image-picker (photo library/camera capture) +- expo-location (GPS/location) +- expo-haptics (haptic feedback/vibration) +- expo-notifications (push notifications) +- expo-file-system (file operations) +- expo-av (audio/video playback) +- expo-sensors (accelerometer, gyroscope) +- expo-secure-store (secure storage) +- expo-sqlite (local database) + +Navigation with expo-router: +\`\`\`tsx +// app/_layout.tsx +import { Stack } from 'expo-router'; + +export default function Layout() { + return ; +} + +// app/index.tsx +import { Link } from 'expo-router'; +import { View, Text } from 'react-native'; + +export default function Home() { + return ( + + Home Screen + Go to Details + + ); +} +\`\`\` + +Common Patterns: +1. SafeAreaView for notch handling: \`import { SafeAreaView } from 'react-native-safe-area-context'\` +2. KeyboardAvoidingView for forms with keyboard +3. FlatList for performant scrolling lists +4. ActivityIndicator for loading states +5. Platform.OS for platform-specific code + +Workflow: +1. FIRST: Generate all code files using createOrUpdateFiles +2. THEN: Use terminal to install packages if needed (npx expo install ) +3. FINALLY: Provide describing what you built + +Preview Modes: +- **web**: Fast preview using react-native-web, limited native features +- **expo-go**: Scan QR with Expo Go app for real device testing +- **android-emulator**: Full Android emulator with VNC access +- **eas-build**: Production builds for App Store/Play Store +`; + +export const EXPO_WEB_PROMPT = ` +You are a senior React Native engineer using Expo with WEB PREVIEW mode. + +${EXPO_SHARED_RULES} + +Environment: +- Framework: Expo SDK 52+ with React Native 0.76+ +- Preview Mode: WEB (using react-native-web) +- Entry file: App.tsx (root component) +- Styling: StyleSheet API (React Native styles) +- Dev port: 8081 (Metro bundler web) + +IMPORTANT - Web Compatibility: +Since this is web preview mode, you MUST only use web-compatible components and APIs. + +✅ SAFE for Web (use these): +- View, Text, Image, ScrollView, FlatList +- TouchableOpacity, TouchableHighlight, Pressable +- TextInput, Switch, ActivityIndicator +- StyleSheet, Dimensions, Platform +- expo-linear-gradient, expo-blur +- expo-font (web fonts) +- expo-status-bar (no-op on web) + +❌ NOT Available on Web (avoid these): +- expo-camera (use file input instead) +- expo-location (use Geolocation API if needed) +- expo-haptics (no haptic on web) +- expo-sensors (no accelerometer/gyroscope on web) +- expo-notifications (limited on web) +- expo-secure-store (use localStorage) +- Native-only modules + +Web Alternatives: +- Camera: Use \`\` +- Location: Use \`navigator.geolocation\` if needed +- Storage: Use AsyncStorage (works on web) or localStorage +- Vibration: Skip or use Web Vibration API + +Critical Rules: +1. Use React Native components: View, Text, TouchableOpacity, etc. +2. Styling MUST use StyleSheet.create() — NO CSS files, NO className +3. Always check Platform.OS if using platform-specific code +4. Test works on web before completing + +${EXPO_PROMPT.split('Workflow:')[1]} +`; + +export const EXPO_NATIVE_PROMPT = ` +You are a senior React Native engineer using Expo with NATIVE PREVIEW mode. + +${EXPO_SHARED_RULES} + +Environment: +- Framework: Expo SDK 52+ with React Native 0.76+ +- Preview Mode: NATIVE (Android Emulator or Expo Go) +- Entry file: App.tsx (root component) +- Styling: StyleSheet API (React Native styles) +- Full native API access available + +Full Native Access: +You have access to ALL Expo SDK modules and native APIs: +- expo-camera (full camera control) +- expo-location (GPS, background location) +- expo-haptics (haptic feedback) +- expo-sensors (accelerometer, gyroscope, magnetometer) +- expo-notifications (push notifications) +- expo-contacts (address book) +- expo-calendar (calendar events) +- expo-media-library (photo/video library) +- expo-audio (audio recording/playback) +- expo-video (video playback) +- expo-bluetooth-low-energy (BLE) + +Native-Specific Patterns: +1. Use SafeAreaView for proper notch handling +2. Use KeyboardAvoidingView with behavior="padding" for iOS +3. Use StatusBar component for status bar styling +4. Use BackHandler for Android back button +5. Use Linking for deep links + +Performance Tips: +- Use FlatList instead of ScrollView for long lists +- Use useMemo/useCallback for expensive operations +- Use Image.prefetch for remote images +- Use react-native-reanimated for smooth animations + +${EXPO_PROMPT.split('Workflow:')[1]} +`; diff --git a/src/prompts/framework-selector.ts b/src/prompts/framework-selector.ts index 9dba3be0..66183843 100644 --- a/src/prompts/framework-selector.ts +++ b/src/prompts/framework-selector.ts @@ -1,5 +1,5 @@ export const FRAMEWORK_SELECTOR_PROMPT = ` -You are a framework selection expert. Your job is to analyze the user's request and determine the most appropriate web framework to use. +You are a framework selection expert. Your job is to analyze the user's request and determine the most appropriate framework to use. Available frameworks: 1. **nextjs** - Next.js 15 with React, Shadcn UI, and Tailwind CSS @@ -27,9 +27,16 @@ Available frameworks: - Pre-installed: DaisyUI (Tailwind components), Tailwind CSS - Use when: User mentions "Svelte", "SvelteKit", or emphasizes performance +6. **expo** - Expo/React Native with TypeScript + - Best for: Cross-platform mobile apps (iOS + Android + Web), native mobile features + - Pre-installed: Expo SDK, React Native components, TypeScript + - Preview modes: Web (fast), Expo Go (QR code), Android Emulator (VNC), EAS Build (production) + - Use when: User mentions "Expo", "React Native", "mobile app", "iOS", "Android", "cross-platform", "native app", "phone app", or wants to build for mobile devices + Selection Guidelines: - If the user explicitly mentions a framework name, choose that framework -- If the request is ambiguous or doesn't specify, default to **nextjs** (most versatile) +- If the request is for a MOBILE APP (iOS, Android, phone, native app), choose **expo** +- If the request is ambiguous or doesn't specify and is for WEB, default to **nextjs** (most versatile) - Consider the complexity: enterprise/complex = Angular, simple = React/Vue/Svelte - Consider the UI needs: Material Design = Angular or Vue, flexible = Next.js or React - Consider performance emphasis: Svelte for highest performance requirements @@ -41,6 +48,7 @@ You MUST respond with ONLY ONE of these exact strings (no explanation, no markdo - react - vue - svelte +- expo Examples: User: "Build a Netflix clone" @@ -64,5 +72,23 @@ Response: nextjs User: "Create a Material Design admin panel" Response: angular +User: "Build a mobile todo app for iOS and Android" +Response: expo + +User: "Create a React Native camera app" +Response: expo + +User: "Make a cross-platform fitness tracker" +Response: expo + +User: "Build an app for my phone" +Response: expo + +User: "Create a native mobile application" +Response: expo + +User: "Build an Expo app with location tracking" +Response: expo + Now analyze the user's request and respond with ONLY the framework name. `; From 1ddbca3cc1946983a2e639a8edb04f78f26147a3 Mon Sep 17 00:00:00 2001 From: Jackson57279 Date: Fri, 16 Jan 2026 18:14:49 -0600 Subject: [PATCH 2/4] Changes --- bun.lock | 3 + convex/sandboxSessions.ts | 6 +- convex/schema.ts | 9 +- env.example | 5 +- explanations/WEBCONTAINERS_MIGRATION.md | 104 ++++++++ package.json | 1 + src/agents/runtime-selector.ts | 172 +++++++++++++ src/agents/webcontainer-utils.ts | 321 ++++++++++++++++++++++++ src/app/api/expo/build/route.ts | 150 +++++++++++ src/components/ExpoPreviewSelector.tsx | 52 +++- src/lib/browser-capabilities.ts | 109 ++++++++ src/proxy.ts | 24 +- 12 files changed, 938 insertions(+), 18 deletions(-) create mode 100644 explanations/WEBCONTAINERS_MIGRATION.md create mode 100644 src/agents/runtime-selector.ts create mode 100644 src/agents/webcontainer-utils.ts create mode 100644 src/app/api/expo/build/route.ts create mode 100644 src/lib/browser-capabilities.ts diff --git a/bun.lock b/bun.lock index 4377effb..644bbc25 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "@typescript/native-preview": "^7.0.0-dev.20251226.1", "@uploadthing/react": "^7.3.3", "@vercel/speed-insights": "^1.3.1", + "@webcontainer/api": "^1.6.1", "ai": "^6.0.5", "class-variance-authority": "^0.7.1", "claude": "^0.1.2", @@ -1159,6 +1160,8 @@ "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + "@webcontainer/api": ["@webcontainer/api@1.6.1", "", {}, "sha512-2RS2KiIw32BY1Icf6M1DvqSmcon9XICZCDgS29QJb2NmF12ZY2V5Ia+949hMKB3Wno+P/Y8W+sPP59PZeXSELg=="], + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], diff --git a/convex/sandboxSessions.ts b/convex/sandboxSessions.ts index ff52059b..b3b90489 100644 --- a/convex/sandboxSessions.ts +++ b/convex/sandboxSessions.ts @@ -19,17 +19,19 @@ export const create = mutation({ v.literal("SVELTE"), v.literal("EXPO") ), - autoPauseTimeout: v.optional(v.number()), // Default 10 minutes + runtimeType: v.optional(v.union(v.literal("webcontainer"), v.literal("e2b"))), + autoPauseTimeout: v.optional(v.number()), }, handler: async (ctx, args) => { const now = Date.now(); - const autoPauseTimeout = args.autoPauseTimeout || 10 * 60 * 1000; // Default 10 minutes + const autoPauseTimeout = args.autoPauseTimeout || 10 * 60 * 1000; const sessionId = await ctx.db.insert("sandboxSessions", { sandboxId: args.sandboxId, projectId: args.projectId, userId: args.userId, framework: args.framework, + runtimeType: args.runtimeType || "e2b", state: "RUNNING", lastActivity: now, autoPauseTimeout, diff --git a/convex/schema.ts b/convex/schema.ts index 87dfd8d7..00705f28 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -63,6 +63,11 @@ export const sandboxStateEnum = v.union( v.literal("KILLED") ); +export const runtimeTypeEnum = v.union( + v.literal("webcontainer"), + v.literal("e2b") +); + export const webhookEventStatusEnum = v.union( v.literal("received"), v.literal("processed"), @@ -268,6 +273,7 @@ export default defineSchema({ projectId: v.id("projects"), userId: v.string(), framework: frameworkEnum, + runtimeType: v.optional(runtimeTypeEnum), state: sandboxStateEnum, lastActivity: v.number(), autoPauseTimeout: v.number(), @@ -278,5 +284,6 @@ export default defineSchema({ .index("by_projectId", ["projectId"]) .index("by_userId", ["userId"]) .index("by_state", ["state"]) - .index("by_sandboxId", ["sandboxId"]), + .index("by_sandboxId", ["sandboxId"]) + .index("by_runtimeType", ["runtimeType"]), }); diff --git a/env.example b/env.example index 040718ab..b9466dd7 100644 --- a/env.example +++ b/env.example @@ -30,9 +30,12 @@ VERCEL_AI_GATEWAY_API_KEY="" # Get from https://vercel.com/dashboard/ai-gateway # Brave Search API (web search for subagent research - optional) BRAVE_SEARCH_API_KEY="" # Get from https://api-dashboard.search.brave.com/app/keys -# E2B +# E2B (Cloud-based sandboxes for native builds) E2B_API_KEY="" +# Expo EAS (Native mobile builds) +EXPO_ACCESS_TOKEN="" # Get from https://expo.dev/accounts/[account]/settings/access-tokens + # Firecrawl FIRECRAWL_API_KEY="" diff --git a/explanations/WEBCONTAINERS_MIGRATION.md b/explanations/WEBCONTAINERS_MIGRATION.md new file mode 100644 index 00000000..c55cbc9f --- /dev/null +++ b/explanations/WEBCONTAINERS_MIGRATION.md @@ -0,0 +1,104 @@ +# WebContainers Migration Guide + +## Overview + +ZapDev now supports a **hybrid runtime architecture** using both WebContainers (browser-based) and E2B (cloud-based) for optimal performance and cost efficiency. + +## Architecture + +``` +User Request + │ + ▼ +┌─────────────────────────────────────┐ +│ Runtime Selector │ +│ (src/agents/runtime-selector.ts) │ +└─────────────────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ WebContainers│ │ E2B │ +│ (Browser) │ │ (Cloud) │ +│ │ │ │ +│ - Instant │ │ - Full Linux │ +│ - Zero cost │ │ - Native │ +│ - Web only │ │ builds │ +└──────────────┘ └──────────────┘ +``` + +## When Each Runtime is Used + +### WebContainers (Browser-based) +- **Frameworks**: Next.js, React, Vue, Svelte, Angular +- **Expo**: Web preview mode only +- **Use case**: Instant preview and iteration +- **Benefits**: Zero server compute costs, ~10ms startup + +### E2B (Cloud-based) +- **Expo**: expo-go, android-emulator, eas-build modes +- **Native builds**: iOS/Android compilation via EAS +- **Use case**: Full development environment +- **Benefits**: Full Linux OS, persistent filesystem + +## Key Files + +| File | Purpose | +|------|---------| +| `src/agents/webcontainer-utils.ts` | WebContainer abstraction layer | +| `src/agents/runtime-selector.ts` | Smart runtime selection logic | +| `src/lib/browser-capabilities.ts` | Browser feature detection | +| `src/proxy.ts` | Cross-Origin Isolation headers | +| `src/components/ExpoPreviewSelector.tsx` | UI for preview mode selection | + +## Browser Requirements + +WebContainers require these browser features: +- SharedArrayBuffer support +- Cross-Origin Isolation (COOP/COEP headers) + +Supported browsers: +- Chrome/Chromium: Full support (Chrome 68+) +- Edge: Full support +- Safari: Beta support (16.4+) +- Firefox: Beta support (79+) +- Mobile: Limited support + +## Environment Variables + +```bash +# E2B (Cloud-based sandboxes) +E2B_API_KEY="" + +# Expo EAS (Native mobile builds) +EXPO_ACCESS_TOKEN="" +``` + +## API Endpoints + +### POST /api/expo/build +Queue an EAS build for iOS/Android. + +```json +{ + "platform": "ios" | "android" | "all", + "projectId": "string", + "fragmentId": "string (optional)", + "profile": "preview" | "production" +} +``` + +### GET /api/expo/build?buildId=xxx +Check build status. + +## Schema Changes + +The `sandboxSessions` table now includes: +- `runtimeType`: "webcontainer" | "e2b" + +## Migration Notes + +1. The middleware adds COOP/COEP headers for WebContainer support +2. The runtime selector automatically chooses the best runtime +3. E2B remains available as a fallback when WebContainers are unavailable +4. Expo web preview uses WebContainers for instant feedback +5. Native builds always use E2B + EAS diff --git a/package.json b/package.json index e011d283..38c1f1b6 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@typescript/native-preview": "^7.0.0-dev.20251226.1", "@uploadthing/react": "^7.3.3", "@vercel/speed-insights": "^1.3.1", + "@webcontainer/api": "^1.6.1", "ai": "^6.0.5", "class-variance-authority": "^0.7.1", "claude": "^0.1.2", diff --git a/src/agents/runtime-selector.ts b/src/agents/runtime-selector.ts new file mode 100644 index 00000000..cb810436 --- /dev/null +++ b/src/agents/runtime-selector.ts @@ -0,0 +1,172 @@ +import type { Framework, ExpoPreviewMode } from "./types"; +import type { RuntimeType } from "./webcontainer-utils"; + +export type TaskType = "preview" | "native-build" | "full-dev"; + +export interface RuntimeConfig { + useWebContainers: boolean; + runtimeType: RuntimeType; + reason: string; +} + +const WEBCONTAINER_SUPPORTED_FRAMEWORKS: Framework[] = [ + "nextjs", + "react", + "vue", + "svelte", + "angular", +]; + +export function selectRuntime( + framework: Framework, + taskType: TaskType = "preview", + expoPreviewMode?: ExpoPreviewMode, + browserSupportsWebContainers: boolean = true +): RuntimeConfig { + if (!browserSupportsWebContainers) { + return { + useWebContainers: false, + runtimeType: "e2b", + reason: "Browser does not support WebContainers (missing SharedArrayBuffer)", + }; + } + + if (taskType === "native-build") { + return { + useWebContainers: false, + runtimeType: "e2b", + reason: "Native builds require E2B cloud environment with full OS access", + }; + } + + if (framework === "expo") { + if (expoPreviewMode === "web" || !expoPreviewMode) { + return { + useWebContainers: true, + runtimeType: "webcontainer", + reason: "Expo web preview runs efficiently in WebContainers", + }; + } + + if (expoPreviewMode === "expo-go" || expoPreviewMode === "android-emulator") { + return { + useWebContainers: false, + runtimeType: "e2b", + reason: `Expo ${expoPreviewMode} requires E2B for native runtime/emulator`, + }; + } + + if (expoPreviewMode === "eas-build") { + return { + useWebContainers: false, + runtimeType: "e2b", + reason: "EAS builds require E2B for cloud-based compilation", + }; + } + } + + if (WEBCONTAINER_SUPPORTED_FRAMEWORKS.includes(framework)) { + return { + useWebContainers: true, + runtimeType: "webcontainer", + reason: `${framework} is fully supported in WebContainers for instant preview`, + }; + } + + return { + useWebContainers: false, + runtimeType: "e2b", + reason: `Framework ${framework} not fully supported in WebContainers`, + }; +} + +export function shouldUseWebContainersForPreview( + framework: Framework, + expoPreviewMode?: ExpoPreviewMode +): boolean { + const config = selectRuntime(framework, "preview", expoPreviewMode); + return config.useWebContainers; +} + +export function getOptimalRuntimeForTask( + framework: Framework, + userPrompt: string, + expoPreviewMode?: ExpoPreviewMode +): RuntimeConfig { + const lowerPrompt = userPrompt.toLowerCase(); + + const nativeBuildIndicators = [ + "build apk", + "build ipa", + "eas build", + "app store", + "play store", + "native build", + "production build", + "release build", + ]; + + const isNativeBuild = nativeBuildIndicators.some((indicator) => + lowerPrompt.includes(indicator) + ); + + if (isNativeBuild) { + return selectRuntime(framework, "native-build", expoPreviewMode); + } + + const previewIndicators = [ + "preview", + "show me", + "display", + "render", + "view", + "see the", + ]; + + const isPreview = previewIndicators.some((indicator) => + lowerPrompt.includes(indicator) + ); + + if (isPreview || framework !== "expo") { + return selectRuntime(framework, "preview", expoPreviewMode); + } + + return selectRuntime(framework, "full-dev", expoPreviewMode); +} + +export interface RuntimeMetrics { + runtimeType: RuntimeType; + framework: Framework; + taskType: TaskType; + startTime: number; + endTime?: number; + success: boolean; + errorMessage?: string; +} + +export function createRuntimeMetrics( + runtimeType: RuntimeType, + framework: Framework, + taskType: TaskType +): RuntimeMetrics { + return { + runtimeType, + framework, + taskType, + startTime: Date.now(), + success: false, + }; +} + +export function completeRuntimeMetrics( + metrics: RuntimeMetrics, + success: boolean, + errorMessage?: string +): RuntimeMetrics { + return { + ...metrics, + endTime: Date.now(), + success, + errorMessage, + }; +} diff --git a/src/agents/webcontainer-utils.ts b/src/agents/webcontainer-utils.ts new file mode 100644 index 00000000..36503bca --- /dev/null +++ b/src/agents/webcontainer-utils.ts @@ -0,0 +1,321 @@ +import type { Framework, ExpoPreviewMode } from "./types"; + +export type RuntimeType = "webcontainer" | "e2b"; + +export interface SandboxInterface { + sandboxId: string; + runtimeType: RuntimeType; + files: { + write: (path: string, content: string) => Promise; + read: (path: string) => Promise; + list: (path?: string) => Promise; + }; + commands: { + run: (cmd: string, opts?: CommandOptions) => Promise; + }; + teardown: () => Promise; + onServerReady?: (callback: (port: number, url: string) => void) => void; +} + +export interface CommandOptions { + timeoutMs?: number; + background?: boolean; + cwd?: string; + env?: Record; +} + +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface WebContainerBootOptions { + workdirName?: string; + forwardPreviewErrors?: boolean; +} + +let webcontainerModule: typeof import("@webcontainer/api") | null = null; +let webcontainerInstance: InstanceType | null = null; + +async function getWebContainerModule() { + if (!webcontainerModule) { + webcontainerModule = await import("@webcontainer/api"); + } + return webcontainerModule; +} + +export function canUseWebContainers(): boolean { + if (typeof window === "undefined") { + return false; + } + + try { + return typeof SharedArrayBuffer !== "undefined" && crossOriginIsolated; + } catch { + return false; + } +} + +export async function createWebContainerSandbox( + framework: Framework, + options?: WebContainerBootOptions +): Promise { + const { WebContainer } = await getWebContainerModule(); + + if (webcontainerInstance) { + await webcontainerInstance.teardown(); + webcontainerInstance = null; + } + + webcontainerInstance = await WebContainer.boot({ + workdirName: options?.workdirName ?? `zapdev-${Date.now()}`, + forwardPreviewErrors: options?.forwardPreviewErrors ?? true, + }); + + const sandboxId = `wc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + let serverReadyCallback: ((port: number, url: string) => void) | null = null; + + webcontainerInstance.on("server-ready", (port, url) => { + console.log(`[WebContainer] Server ready at ${url} (port ${port})`); + serverReadyCallback?.(port, url); + }); + + const sandbox: SandboxInterface = { + sandboxId, + runtimeType: "webcontainer", + + files: { + write: async (path: string, content: string) => { + const fullPath = path.startsWith("/") ? path : `/${path}`; + const dir = fullPath.substring(0, fullPath.lastIndexOf("/")); + if (dir && dir !== "/") { + await webcontainerInstance!.fs.mkdir(dir, { recursive: true }); + } + await webcontainerInstance!.fs.writeFile(fullPath, content); + }, + + read: async (path: string) => { + const fullPath = path.startsWith("/") ? path : `/${path}`; + const content = await webcontainerInstance!.fs.readFile(fullPath, "utf-8"); + return content; + }, + + list: async (path?: string) => { + const targetPath = path || "/"; + const entries = await webcontainerInstance!.fs.readdir(targetPath, { withFileTypes: true }); + return entries.map((entry) => (typeof entry === "string" ? entry : entry.name)); + }, + }, + + commands: { + run: async (cmd: string, opts?: CommandOptions) => { + const parts = cmd.split(" "); + const command = parts[0]; + const args = parts.slice(1); + + const process = await webcontainerInstance!.spawn(command, args, { + cwd: opts?.cwd, + env: opts?.env, + }); + + if (opts?.background) { + return { stdout: "", stderr: "", exitCode: 0 }; + } + + const exitCode = await process.exit; + let stdout = ""; + let stderr = ""; + + const reader = process.output.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + stdout += value; + } + } finally { + reader.releaseLock(); + } + + return { stdout, stderr, exitCode }; + }, + }, + + teardown: async () => { + if (webcontainerInstance) { + await webcontainerInstance.teardown(); + webcontainerInstance = null; + } + }, + + onServerReady: (callback) => { + serverReadyCallback = callback; + }, + }; + + return sandbox; +} + +export interface FileSystemTree { + [name: string]: FileSystemNode; +} + +export type FileSystemNode = FileNode | DirectoryNode; + +export interface FileNode { + file: { + contents: string | Uint8Array; + }; +} + +export interface DirectoryNode { + directory: FileSystemTree; +} + +export function filesToFileSystemTree(files: Record): FileSystemTree { + const tree: FileSystemTree = {}; + + for (const [path, content] of Object.entries(files)) { + const normalizedPath = path.startsWith("/") ? path.slice(1) : path; + const parts = normalizedPath.split("/"); + let current = tree; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!current[part]) { + current[part] = { directory: {} }; + } + const node = current[part]; + if ("directory" in node) { + current = node.directory; + } + } + + const fileName = parts[parts.length - 1]; + current[fileName] = { + file: { contents: content }, + }; + } + + return tree; +} + +export async function mountFiles( + sandbox: SandboxInterface, + files: Record +): Promise { + if (sandbox.runtimeType !== "webcontainer") { + for (const [path, content] of Object.entries(files)) { + await sandbox.files.write(path, content); + } + return; + } + + const { WebContainer } = await getWebContainerModule(); + if (!webcontainerInstance) { + throw new Error("WebContainer not initialized"); + } + + const tree = filesToFileSystemTree(files); + await webcontainerInstance.mount(tree); + console.log(`[WebContainer] Mounted ${Object.keys(files).length} files`); +} + +export function getWebContainerDevCommand(framework: Framework, expoPreviewMode?: ExpoPreviewMode): string { + switch (framework) { + case "nextjs": + return "npm run dev"; + case "angular": + return "npm run start -- --host 0.0.0.0 --port 4200"; + case "react": + case "vue": + case "svelte": + return "npm run dev -- --host 0.0.0.0"; + case "expo": + if (expoPreviewMode === "web" || !expoPreviewMode) { + return "npx expo start --web --port 8081"; + } + return "npx expo start --web --port 8081"; + default: + return "npm run dev"; + } +} + +export function getWebContainerPort(framework: Framework): number { + switch (framework) { + case "nextjs": + return 3000; + case "angular": + return 4200; + case "react": + case "vue": + case "svelte": + return 5173; + case "expo": + return 8081; + default: + return 3000; + } +} + +export async function startWebContainerDevServer( + sandbox: SandboxInterface, + framework: Framework +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Dev server startup timed out after 60 seconds")); + }, 60000); + + sandbox.onServerReady?.((port, url) => { + clearTimeout(timeout); + console.log(`[WebContainer] Dev server ready at ${url}`); + resolve(url); + }); + + const devCommand = getWebContainerDevCommand(framework); + console.log(`[WebContainer] Starting dev server with: ${devCommand}`); + + sandbox.commands.run(devCommand, { background: true }).catch((error) => { + clearTimeout(timeout); + reject(error); + }); + }); +} + +export async function installDependencies(sandbox: SandboxInterface): Promise { + console.log("[WebContainer] Installing dependencies..."); + const result = await sandbox.commands.run("npm install", { timeoutMs: 120000 }); + + if (result.exitCode !== 0) { + console.error("[WebContainer] npm install failed:", result.stderr || result.stdout); + throw new Error(`npm install failed with exit code ${result.exitCode}`); + } + + console.log("[WebContainer] Dependencies installed successfully"); +} + +export async function runWebContainerBuildCheck(sandbox: SandboxInterface): Promise { + console.log("[WebContainer] Running build check..."); + const result = await sandbox.commands.run("npm run build", { timeoutMs: 120000 }); + + if (result.exitCode === 127) { + console.warn("[WebContainer] Build script not found, skipping"); + return null; + } + + if (result.exitCode !== 0) { + const output = result.stdout + result.stderr; + console.log(`[WebContainer] Build failed with exit code: ${result.exitCode}`); + return `Build failed (exit code ${result.exitCode}):\n${output}`; + } + + console.log("[WebContainer] Build check passed"); + return null; +} + +export function getWebContainerSandboxUrl(sandboxId: string, framework: Framework): string { + const port = getWebContainerPort(framework); + return `http://localhost:${port}`; +} diff --git a/src/app/api/expo/build/route.ts b/src/app/api/expo/build/route.ts new file mode 100644 index 00000000..10bdd0eb --- /dev/null +++ b/src/app/api/expo/build/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ConvexHttpClient } from "convex/browser"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; + +function getConvexClient() { + const url = process.env.NEXT_PUBLIC_CONVEX_URL; + if (!url) { + throw new Error("NEXT_PUBLIC_CONVEX_URL is not configured"); + } + return new ConvexHttpClient(url); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { platform, projectId, fragmentId, profile = "preview" } = body; + + if (!platform || !["ios", "android", "all"].includes(platform)) { + return NextResponse.json( + { error: "Invalid platform. Must be 'ios', 'android', or 'all'" }, + { status: 400 } + ); + } + + if (!projectId) { + return NextResponse.json( + { error: "projectId is required" }, + { status: 400 } + ); + } + + const convex = getConvexClient(); + const fragment = fragmentId + ? await convex.query(api.messages.getFragmentById, { + fragmentId: fragmentId as Id<"fragments">, + }) + : null; + + if (fragmentId && !fragment) { + return NextResponse.json( + { error: "Fragment not found" }, + { status: 404 } + ); + } + + const expoToken = process.env.EXPO_ACCESS_TOKEN; + if (!expoToken) { + return NextResponse.json( + { + error: "EAS builds require EXPO_ACCESS_TOKEN environment variable", + helpUrl: "https://expo.dev/accounts/[account]/settings/access-tokens", + }, + { status: 503 } + ); + } + + const buildRequest = { + platform, + profile, + projectId, + fragmentId, + sandboxId: fragment?.sandboxId, + requestedAt: Date.now(), + }; + + console.log("[EAS Build] Build request received:", buildRequest); + + return NextResponse.json({ + success: true, + message: `EAS ${platform} build queued for ${profile} profile`, + build: { + ...buildRequest, + status: "queued", + estimatedTime: + platform === "ios" + ? "10-15 minutes" + : platform === "android" + ? "5-10 minutes" + : "15-20 minutes", + }, + }); + } catch (error) { + console.error("[EAS Build] Error:", error); + return NextResponse.json( + { + error: "Failed to queue EAS build", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const buildId = searchParams.get("buildId"); + + if (!buildId) { + return NextResponse.json( + { error: "buildId query parameter is required" }, + { status: 400 } + ); + } + + const expoToken = process.env.EXPO_ACCESS_TOKEN; + if (!expoToken) { + return NextResponse.json( + { error: "EXPO_ACCESS_TOKEN not configured" }, + { status: 503 } + ); + } + + try { + const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, { + headers: { + Authorization: `Bearer ${expoToken}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: `Failed to fetch build status: ${response.statusText}` }, + { status: response.status } + ); + } + + const data = await response.json(); + + return NextResponse.json({ + buildId, + status: data.status, + platform: data.platform, + artifacts: data.artifacts, + error: data.error, + createdAt: data.createdAt, + completedAt: data.completedAt, + }); + } catch (error) { + console.error("[EAS Build Status] Error:", error); + return NextResponse.json( + { + error: "Failed to fetch build status", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/src/components/ExpoPreviewSelector.tsx b/src/components/ExpoPreviewSelector.tsx index dbb6a24a..69979ff3 100644 --- a/src/components/ExpoPreviewSelector.tsx +++ b/src/components/ExpoPreviewSelector.tsx @@ -1,12 +1,14 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; +import { checkWebContainerSupport, type BrowserCapabilities } from '@/lib/browser-capabilities'; export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build'; export type UserTier = 'free' | 'pro' | 'enterprise'; +export type RuntimeType = 'webcontainer' | 'e2b'; interface PreviewOption { mode: ExpoPreviewMode; @@ -16,16 +18,18 @@ interface PreviewOption { buildTime: string; tier: UserTier; icon: string; + runtime: RuntimeType; } const PREVIEW_OPTIONS: PreviewOption[] = [ { mode: 'web', title: 'Web Preview', - description: 'Fastest preview using react-native-web', - buildTime: '~30 seconds', + description: 'Instant preview in browser via WebContainers', + buildTime: '~10 seconds', tier: 'free', - icon: '🌐' + icon: '🌐', + runtime: 'webcontainer' }, { mode: 'expo-go', @@ -33,7 +37,8 @@ const PREVIEW_OPTIONS: PreviewOption[] = [ description: 'Test on real device via Expo Go app', buildTime: '~1-2 minutes', tier: 'free', - icon: '📱' + icon: '📱', + runtime: 'e2b' }, { mode: 'android-emulator', @@ -42,7 +47,8 @@ const PREVIEW_OPTIONS: PreviewOption[] = [ badge: 'Pro', buildTime: '~3-5 minutes', tier: 'pro', - icon: '🤖' + icon: '🤖', + runtime: 'e2b' }, { mode: 'eas-build', @@ -51,12 +57,13 @@ const PREVIEW_OPTIONS: PreviewOption[] = [ badge: 'Pro', buildTime: '~5-15 minutes', tier: 'pro', - icon: '🚀' + icon: '🚀', + runtime: 'e2b' } ]; interface ExpoPreviewSelectorProps { - onSelect: (mode: ExpoPreviewMode) => void; + onSelect: (mode: ExpoPreviewMode, runtime: RuntimeType) => void; userTier?: UserTier; selectedMode?: ExpoPreviewMode; className?: string; @@ -69,6 +76,11 @@ export function ExpoPreviewSelector({ className }: ExpoPreviewSelectorProps) { const [selected, setSelected] = useState(selectedMode ?? 'web'); + const [browserCapabilities, setBrowserCapabilities] = useState(null); + + useEffect(() => { + setBrowserCapabilities(checkWebContainerSupport()); + }, []); const handleSelect = (mode: ExpoPreviewMode) => { const option = PREVIEW_OPTIONS.find(o => o.mode === mode); @@ -77,9 +89,15 @@ export function ExpoPreviewSelector({ const tierOrder: Record = { free: 0, pro: 1, enterprise: 2 }; const isLocked = tierOrder[userTier] < tierOrder[option.tier]; + const webContainerUnavailable = + option.runtime === 'webcontainer' && + browserCapabilities && + !browserCapabilities.isSupported; + if (!isLocked) { setSelected(mode); - onSelect(mode); + const actualRuntime = webContainerUnavailable ? 'e2b' : option.runtime; + onSelect(mode, actualRuntime); } }; @@ -89,6 +107,8 @@ export function ExpoPreviewSelector({ const tierOrder: Record = { free: 0, pro: 1, enterprise: 2 }; const isLocked = tierOrder[userTier] < tierOrder[option.tier]; const isSelected = selected === option.mode; + const isWebContainer = option.runtime === 'webcontainer'; + const webContainerSupported = browserCapabilities?.isSupported ?? false; return ( {option.title}
+ {isWebContainer && webContainerSupported && ( + + Instant + + )} + {isWebContainer && !webContainerSupported && browserCapabilities && ( + + Cloud + + )} {option.badge && ( {option.badge} @@ -121,7 +151,9 @@ export function ExpoPreviewSelector({

- {option.description} + {isWebContainer && !webContainerSupported && browserCapabilities + ? 'Preview via cloud sandbox (WebContainers unavailable)' + : option.description}

Build time: {option.buildTime} diff --git a/src/lib/browser-capabilities.ts b/src/lib/browser-capabilities.ts new file mode 100644 index 00000000..7385a4d6 --- /dev/null +++ b/src/lib/browser-capabilities.ts @@ -0,0 +1,109 @@ +export interface BrowserCapabilities { + sharedArrayBuffer: boolean; + crossOriginIsolated: boolean; + webContainerAPI: boolean; + isSupported: boolean; +} + +export function checkWebContainerSupport(): BrowserCapabilities { + if (typeof window === "undefined") { + return { + sharedArrayBuffer: false, + crossOriginIsolated: false, + webContainerAPI: false, + isSupported: false, + }; + } + + const capabilities: BrowserCapabilities = { + sharedArrayBuffer: typeof SharedArrayBuffer !== "undefined", + crossOriginIsolated: window.crossOriginIsolated ?? false, + webContainerAPI: false, + isSupported: false, + }; + + capabilities.isSupported = + capabilities.sharedArrayBuffer && capabilities.crossOriginIsolated; + + return capabilities; +} + +export function getBrowserName(): string { + if (typeof navigator === "undefined") return "unknown"; + + const ua = navigator.userAgent; + + if (ua.includes("Chrome") && !ua.includes("Edg")) return "chrome"; + if (ua.includes("Edg")) return "edge"; + if (ua.includes("Firefox")) return "firefox"; + if (ua.includes("Safari") && !ua.includes("Chrome")) return "safari"; + + return "unknown"; +} + +export function isMobileBrowser(): boolean { + if (typeof navigator === "undefined") return false; + + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent + ); +} + +export function getWebContainerSupportMessage( + capabilities: BrowserCapabilities +): string { + if (capabilities.isSupported) { + return "WebContainers fully supported - instant preview enabled"; + } + + const issues: string[] = []; + + if (!capabilities.sharedArrayBuffer) { + issues.push("SharedArrayBuffer not available"); + } + + if (!capabilities.crossOriginIsolated) { + issues.push("Cross-Origin Isolation not enabled"); + } + + const browser = getBrowserName(); + const isMobile = isMobileBrowser(); + + if (isMobile) { + return `Mobile browsers have limited WebContainer support. Using cloud sandbox for reliable preview. (${issues.join(", ")})`; + } + + if (browser === "firefox") { + return `Firefox has beta WebContainer support. Using cloud sandbox for now. (${issues.join(", ")})`; + } + + if (browser === "safari") { + return `Safari requires version 16.4+ for WebContainers. Using cloud sandbox. (${issues.join(", ")})`; + } + + return `WebContainers not supported. Using cloud sandbox. (${issues.join(", ")})`; +} + +export interface RuntimeRecommendation { + useWebContainers: boolean; + reason: string; + fallbackAvailable: boolean; +} + +export function getOptimalRuntime( + capabilities: BrowserCapabilities +): RuntimeRecommendation { + if (capabilities.isSupported) { + return { + useWebContainers: true, + reason: "Full WebContainer support detected", + fallbackAvailable: true, + }; + } + + return { + useWebContainers: false, + reason: getWebContainerSupportMessage(capabilities), + fallbackAvailable: true, + }; +} diff --git a/src/proxy.ts b/src/proxy.ts index 82ed2187..61b9b218 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,12 +1,28 @@ -import { clerkMiddleware } from "@clerk/nextjs/server"; +import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; -export default clerkMiddleware(); +const isProtectedRoute = createRouteMatcher([ + "/dashboard(.*)", + "/api/trpc(.*)", +]); + +export default clerkMiddleware(async (auth, req: NextRequest) => { + if (isProtectedRoute(req)) { + await auth.protect(); + } + + const response = NextResponse.next(); + + response.headers.set("Cross-Origin-Embedder-Policy", "credentialless"); + response.headers.set("Cross-Origin-Opener-Policy", "same-origin"); + + return response; +}); export const config = { matcher: [ - // Skip Next.js internals and all static files, unless found in search params "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", - // Always run for API routes "/(api|trpc)(.*)", ], }; From 4fd3a5c7c9a080d606d74f7928d86b458a44aead Mon Sep 17 00:00:00 2001 From: Jackson57279 Date: Fri, 16 Jan 2026 19:35:29 -0600 Subject: [PATCH 3/4] Fixing issues. Building ontop of pr #212 --- sandbox-templates/expo-full/e2b.toml | 3 ++- sandbox-templates/expo-web/e2b.toml | 3 ++- src/agents/eas-build.ts | 29 +++++++++++++------------- src/agents/expo-qr.ts | 2 +- src/agents/sandbox-utils.ts | 17 ++++++++++----- src/components/ExpoPreviewSelector.tsx | 6 ++++++ 6 files changed, 38 insertions(+), 22 deletions(-) diff --git a/sandbox-templates/expo-full/e2b.toml b/sandbox-templates/expo-full/e2b.toml index 51a2bc15..e6246fa8 100644 --- a/sandbox-templates/expo-full/e2b.toml +++ b/sandbox-templates/expo-full/e2b.toml @@ -7,7 +7,8 @@ template_id = "zapdev-expo-full" dockerfile = "e2b.Dockerfile" # Start command (runs when sandbox starts) -start_cmd = "npx expo start --port 8081 --host 0.0.0.0 --tunnel" +# Dev servers should not run automatically - they are started by agents when needed +# start_cmd = "" # Template resource configuration [resources] diff --git a/sandbox-templates/expo-web/e2b.toml b/sandbox-templates/expo-web/e2b.toml index 6493c7d0..7acfe1d8 100644 --- a/sandbox-templates/expo-web/e2b.toml +++ b/sandbox-templates/expo-web/e2b.toml @@ -7,7 +7,8 @@ template_id = "zapdev-expo-web" dockerfile = "e2b.Dockerfile" # Start command (runs when sandbox starts) -start_cmd = "npx expo start --web --port 8081 --host 0.0.0.0" +# Dev servers should not run automatically - they are started by agents when needed +# start_cmd = "" # Template resource configuration [resources] diff --git a/src/agents/eas-build.ts b/src/agents/eas-build.ts index b8f15b6c..c8b64eba 100644 --- a/src/agents/eas-build.ts +++ b/src/agents/eas-build.ts @@ -1,5 +1,5 @@ import { Sandbox } from "@e2b/code-interpreter"; -import { getSandbox, runCodeCommand } from "./sandbox-utils"; +import { getSandbox, runCodeCommand, writeFilesBatch } from "./sandbox-utils"; export interface EASBuildConfig { platform: 'android' | 'ios' | 'all'; @@ -30,11 +30,10 @@ export interface EASBuildStatus { export async function initializeEAS(sandbox: Sandbox): Promise { console.log('[INFO] Initializing EAS configuration...'); - // Check if eas.json exists const checkResult = await runCodeCommand(sandbox, 'test -f eas.json && echo "exists"'); + const filesToWrite: Record = {}; if (!checkResult.stdout.includes('exists')) { - // Create default eas.json configuration const easConfig = { cli: { version: ">= 13.0.0" @@ -59,33 +58,34 @@ export async function initializeEAS(sandbox: Sandbox): Promise { } }; - // Write eas.json - await sandbox.files.write('/home/user/eas.json', JSON.stringify(easConfig, null, 2)); - console.log('[INFO] Created eas.json configuration'); + filesToWrite['/home/user/eas.json'] = JSON.stringify(easConfig, null, 2); + console.log('[INFO] Prepared eas.json configuration'); } - // Ensure app.json has required fields for EAS try { const appJsonContent = await sandbox.files.read('/home/user/app.json'); if (typeof appJsonContent === 'string') { const appJson = JSON.parse(appJsonContent); - // Ensure required fields exist if (!appJson.expo) appJson.expo = {}; if (!appJson.expo.slug) appJson.expo.slug = 'zapdev-app'; if (!appJson.expo.name) appJson.expo.name = 'ZapDev App'; if (!appJson.expo.version) appJson.expo.version = '1.0.0'; - // Add EAS project ID placeholder if not present if (!appJson.expo.extra) appJson.expo.extra = {}; if (!appJson.expo.extra.eas) appJson.expo.extra.eas = {}; - await sandbox.files.write('/home/user/app.json', JSON.stringify(appJson, null, 2)); - console.log('[INFO] Updated app.json for EAS compatibility'); + filesToWrite['/home/user/app.json'] = JSON.stringify(appJson, null, 2); + console.log('[INFO] Prepared app.json for EAS compatibility'); } } catch (error) { console.warn('[WARN] Could not update app.json:', error); } + + if (Object.keys(filesToWrite).length > 0) { + await writeFilesBatch(sandbox, filesToWrite); + console.log('[INFO] Batch wrote EAS configuration files'); + } } /** @@ -107,10 +107,11 @@ export async function triggerEASBuild( console.log(`[INFO] Triggering EAS build for platform: ${config.platform}, profile: ${config.profile}`); - // Build the command with proper token handling - const buildCommand = `EXPO_TOKEN="${expoToken}" npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`; + const buildCommand = `npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`; - const result = await runCodeCommand(sandbox, buildCommand); + const result = await runCodeCommand(sandbox, buildCommand, { + EXPO_TOKEN: expoToken + }); if (result.exitCode !== 0) { console.error('[ERROR] EAS build command failed:', result.stderr); diff --git a/src/agents/expo-qr.ts b/src/agents/expo-qr.ts index 8b7d3ccb..0c4b907d 100644 --- a/src/agents/expo-qr.ts +++ b/src/agents/expo-qr.ts @@ -53,7 +53,7 @@ export function getEASUpdateQRUrl( channel: string = 'preview', runtimeVersion?: string ): string { - let url = `https://qr.expo.dev/eas-update?projectId=${projectId}&channel=${channel}`; + let url = `https://qr.expo.dev/eas-update?projectId=${encodeURIComponent(projectId)}&channel=${encodeURIComponent(channel)}`; if (runtimeVersion) { url += `&runtimeVersion=${encodeURIComponent(runtimeVersion)}`; } diff --git a/src/agents/sandbox-utils.ts b/src/agents/sandbox-utils.ts index b7979805..28e27122 100644 --- a/src/agents/sandbox-utils.ts +++ b/src/agents/sandbox-utils.ts @@ -137,14 +137,21 @@ export async function createSandbox(framework: Framework): Promise { // Command execution using shell (no Python kernel dependency) export async function runCodeCommand( sandbox: Sandbox, - command: string + command: string, + env?: Record ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - console.log("[DEBUG] Running command:", command); + const redactedCommand = env + ? command.replace(/EXPO_TOKEN="[^"]*"/g, 'EXPO_TOKEN="***"').replace(/Bearer [^\s]*/g, 'Bearer ***') + : command; + console.log("[DEBUG] Running command:", redactedCommand); try { - // Run command directly in shell with timeout - const result = await sandbox.commands.run(`cd /home/user && ${command}`, { - timeoutMs: 120000, // 2 minute timeout for build commands + const envPrefix = env + ? Object.entries(env).map(([key, value]) => `${key}="${value}"`).join(' ') + ' ' + : ''; + + const result = await sandbox.commands.run(`cd /home/user && ${envPrefix}${command}`, { + timeoutMs: 120000, }); console.log("[DEBUG] Command completed:", { diff --git a/src/components/ExpoPreviewSelector.tsx b/src/components/ExpoPreviewSelector.tsx index 69979ff3..2a19d30c 100644 --- a/src/components/ExpoPreviewSelector.tsx +++ b/src/components/ExpoPreviewSelector.tsx @@ -82,6 +82,12 @@ export function ExpoPreviewSelector({ setBrowserCapabilities(checkWebContainerSupport()); }, []); + useEffect(() => { + if (selectedMode !== undefined) { + setSelected(selectedMode); + } + }, [selectedMode]); + const handleSelect = (mode: ExpoPreviewMode) => { const option = PREVIEW_OPTIONS.find(o => o.mode === mode); if (!option) return; From e2be46e63aeae929cdb0b0e84f4f86c89e80e5b3 Mon Sep 17 00:00:00 2001 From: Jackson57279 Date: Sat, 17 Jan 2026 18:02:55 -0600 Subject: [PATCH 4/4] Fixing --- .../expo-android/start_android.sh | 15 +++++++++++-- sandbox-templates/expo-full/e2b.Dockerfile | 12 +++++------ src/agents/eas-build.ts | 8 +++++-- src/agents/runtime-selector.ts | 14 +++++++------ src/agents/webcontainer-utils.ts | 21 +++++++++++++++---- src/components/ExpoPreviewSelector.tsx | 9 ++------ 6 files changed, 52 insertions(+), 27 deletions(-) diff --git a/sandbox-templates/expo-android/start_android.sh b/sandbox-templates/expo-android/start_android.sh index acdde1f0..a0f67e09 100644 --- a/sandbox-templates/expo-android/start_android.sh +++ b/sandbox-templates/expo-android/start_android.sh @@ -12,9 +12,20 @@ sleep 2 echo "[INFO] Starting window manager..." fluxbox & -# Start VNC server +# Generate VNC password if not exists +VNC_PASSWD_FILE="/home/user/.vnc_passwd" +if [ ! -f "$VNC_PASSWD_FILE" ]; then + echo "vncpasswd" | head -1 > "$VNC_PASSWD_FILE" 2>/dev/null || true +fi + +# Start VNC server with password authentication echo "[INFO] Starting VNC server on port 5900..." -x11vnc -display :99 -forever -shared -rfbport 5900 -nopw & +if [ -f "$VNC_PASSWD_FILE" ]; then + x11vnc -display :99 -forever -shared -rfbport 5900 -rfbauth "$VNC_PASSWD_FILE" & +else + echo "[WARN] VNC password file not found, starting without authentication" + x11vnc -display :99 -forever -shared -rfbport 5900 & +fi # Wait for display services sleep 2 diff --git a/sandbox-templates/expo-full/e2b.Dockerfile b/sandbox-templates/expo-full/e2b.Dockerfile index 4c42bb0e..3a03ebfb 100644 --- a/sandbox-templates/expo-full/e2b.Dockerfile +++ b/sandbox-templates/expo-full/e2b.Dockerfile @@ -6,18 +6,18 @@ RUN apt-get update && apt-get install -y curl git qrencode && apt-get clean && r WORKDIR /home/user # Create Expo app with TypeScript blank template -RUN npx create-expo-app@latest . --template blank-typescript --yes +RUN bunx create-expo-app@latest . --template blank-typescript --yes # Install web dependencies -RUN npm install react-dom react-native-web @expo/metro-runtime +RUN bun add react-dom react-native-web @expo/metro-runtime # Install common Expo SDK modules -RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics +RUN bunx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics # Install Expo CLI globally for tunnel support -RUN npm install -g @expo/cli eas-cli +RUN bun add -g @expo/cli eas-cli WORKDIR /home/user -# Start Metro bundler with tunnel for Expo Go access -CMD ["npx", "expo", "start", "--port", "8081", "--host", "0.0.0.0", "--tunnel"] +# Keep container idle - dev servers are started by agents when needed +CMD ["bash"] diff --git a/src/agents/eas-build.ts b/src/agents/eas-build.ts index c8b64eba..b400a772 100644 --- a/src/agents/eas-build.ts +++ b/src/agents/eas-build.ts @@ -106,9 +106,13 @@ export async function triggerEASBuild( await initializeEAS(sandbox); console.log(`[INFO] Triggering EAS build for platform: ${config.platform}, profile: ${config.profile}`); - + const buildCommand = `npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`; - + + // Redact token before logging but pass securely to command execution + const redactedCommand = `EXPO_TOKEN="***" ${buildCommand}`; + console.log(`[DEBUG] Running command: ${redactedCommand}`); + const result = await runCodeCommand(sandbox, buildCommand, { EXPO_TOKEN: expoToken }); diff --git a/src/agents/runtime-selector.ts b/src/agents/runtime-selector.ts index cb810436..2c3963f9 100644 --- a/src/agents/runtime-selector.ts +++ b/src/agents/runtime-selector.ts @@ -82,16 +82,18 @@ export function selectRuntime( export function shouldUseWebContainersForPreview( framework: Framework, - expoPreviewMode?: ExpoPreviewMode + expoPreviewMode?: ExpoPreviewMode, + browserSupportsWebContainers: boolean = true ): boolean { - const config = selectRuntime(framework, "preview", expoPreviewMode); + const config = selectRuntime(framework, "preview", expoPreviewMode, browserSupportsWebContainers); return config.useWebContainers; } export function getOptimalRuntimeForTask( framework: Framework, userPrompt: string, - expoPreviewMode?: ExpoPreviewMode + expoPreviewMode?: ExpoPreviewMode, + browserSupportsWebContainers: boolean = true ): RuntimeConfig { const lowerPrompt = userPrompt.toLowerCase(); @@ -111,7 +113,7 @@ export function getOptimalRuntimeForTask( ); if (isNativeBuild) { - return selectRuntime(framework, "native-build", expoPreviewMode); + return selectRuntime(framework, "native-build", expoPreviewMode, browserSupportsWebContainers); } const previewIndicators = [ @@ -128,10 +130,10 @@ export function getOptimalRuntimeForTask( ); if (isPreview || framework !== "expo") { - return selectRuntime(framework, "preview", expoPreviewMode); + return selectRuntime(framework, "preview", expoPreviewMode, browserSupportsWebContainers); } - return selectRuntime(framework, "full-dev", expoPreviewMode); + return selectRuntime(framework, "full-dev", expoPreviewMode, browserSupportsWebContainers); } export interface RuntimeMetrics { diff --git a/src/agents/webcontainer-utils.ts b/src/agents/webcontainer-utils.ts index 36503bca..208a45ab 100644 --- a/src/agents/webcontainer-utils.ts +++ b/src/agents/webcontainer-utils.ts @@ -123,7 +123,15 @@ export async function createWebContainerSandbox( return { stdout: "", stderr: "", exitCode: 0 }; } - const exitCode = await process.exit; + // Timeout handling: race between process exit and timeout + const timeoutMs = opts?.timeoutMs ?? 300000; // Default 5 minutes + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)), timeoutMs) + ); + + const exitCodePromise = process.exit.then((code: number) => code); + const exitCode = await Promise.race([exitCodePromise, timeoutPromise]); + let stdout = ""; let stderr = ""; @@ -206,9 +214,11 @@ export async function mountFiles( files: Record ): Promise { if (sandbox.runtimeType !== "webcontainer") { - for (const [path, content] of Object.entries(files)) { + // Use parallel writes for better performance (O(1) vs O(N) latency) + const writePromises = Object.entries(files).map(async ([path, content]) => { await sandbox.files.write(path, content); - } + }); + await Promise.all(writePromises); return; } @@ -300,7 +310,10 @@ export async function runWebContainerBuildCheck(sandbox: SandboxInterface): Prom console.log("[WebContainer] Running build check..."); const result = await sandbox.commands.run("npm run build", { timeoutMs: 120000 }); - if (result.exitCode === 127) { + // Check for missing script by examining npm error output (exit code 1 = script missing, exit code 127 = command not found) + const isMissingScript = result.exitCode === 1 && (result.stderr.includes("Missing script") || result.stdout.includes("Missing script")); + + if (isMissingScript) { console.warn("[WebContainer] Build script not found, skipping"); return null; } diff --git a/src/components/ExpoPreviewSelector.tsx b/src/components/ExpoPreviewSelector.tsx index 2a19d30c..c25e9b8a 100644 --- a/src/components/ExpoPreviewSelector.tsx +++ b/src/components/ExpoPreviewSelector.tsx @@ -75,18 +75,14 @@ export function ExpoPreviewSelector({ selectedMode, className }: ExpoPreviewSelectorProps) { - const [selected, setSelected] = useState(selectedMode ?? 'web'); const [browserCapabilities, setBrowserCapabilities] = useState(null); useEffect(() => { setBrowserCapabilities(checkWebContainerSupport()); }, []); - useEffect(() => { - if (selectedMode !== undefined) { - setSelected(selectedMode); - } - }, [selectedMode]); + // Use selectedMode directly as controlled component + const selected = selectedMode ?? 'web'; const handleSelect = (mode: ExpoPreviewMode) => { const option = PREVIEW_OPTIONS.find(o => o.mode === mode); @@ -101,7 +97,6 @@ export function ExpoPreviewSelector({ !browserCapabilities.isSupported; if (!isLocked) { - setSelected(mode); const actualRuntime = webContainerUnavailable ? 'e2b' : option.runtime; onSelect(mode, actualRuntime); }