Conversation
CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎
Codebase SummaryZapDev is an AI-powered development platform enabling real-time sandboxed web application development. It supports a variety of frameworks including Next.js and now Expo for cross-platform mobile development. The system uses AI agents to generate code in sandboxes, supports WebContainers for instant preview, and integrates with E2B and now Expo EAS for native builds. PR ChangesThis PR integrates Expo support by adding multiple preview modes (web preview, Expo Go via QR code, Android emulator, and EAS Build for production). It also adds runtime selection logic for WebContainers vs. E2B using browser capabilities. New UI components like ExpoPreviewSelector have been added, and prompts/content for Expo have been introduced. Additionally, dependencies in package.json and bun.lock have been updated to include @webcontainer/api, qrcode, and related type definitions. Setup Instructions
Generated Test Cases1: Expo Preview Selector Options Render Correctly ❗️❗️❗️Description: This test verifies that the Expo preview selector component displays all available preview options (Web Preview, Expo Go, Android Emulator, and EAS Build) with the correct titles, icons, and badges. It ensures the component visually reflects which option uses WebContainers ('Instant') versus cloud-based sandbox ('Cloud'). Prerequisites:
Steps:
Expected Result: All preview options are rendered with the correct titles, icons, and badges. The 'Web Preview' option should indicate the use of WebContainers if available. No card appears missing or with misaligned text. 2: Disabled Selection for Locked Preview Options Based on User Tier ❗️❗️❗️Description: This test validates that when the user is on a free tier, preview options requiring a Pro subscription (like Android Emulator and EAS Build) appear disabled or with a lock indicator, preventing selection. Prerequisites:
Steps:
Expected Result: Preview options that require a Pro tier are rendered in a disabled state and are not selectable by a free-tier user. 3: Framework Selector Defaults to Expo for Mobile App Requests ❗️❗️Description: This test ensures that when a user inputs a request related to mobile app development (e.g. mentioning iOS, Android, or native), the framework selector auto-chooses 'expo' as the recommended framework. Prerequisites:
Steps:
Expected Result: The framework selector responds with 'expo' for mobile app related requests, in line with the updated selection guidelines. 4: Error Message Display When EXPO_ACCESS_TOKEN is Missing for Build ❗️❗️❗️Description: This test checks how the application handles a missing EXPO_ACCESS_TOKEN when an Expo build is triggered. It should display a clear error message instructing the user to set the token. Prerequisites:
Steps:
Expected Result: The UI displays a clear error message indicating that EXPO_ACCESS_TOKEN is missing and provides a help URL, such as instructions to get one from the Expo dev account. 5: WebContainers Fallback Message on Unsupported Browser ❗️❗️Description: This test verifies that if the browser does not support WebContainers (simulated by disabling SharedArrayBuffer or crossOriginIsolation), the Expo preview selector informs the user that the preview will run in a cloud sandbox instead. Prerequisites:
Steps:
Expected Result: The UI clearly indicates that WebContainers are not supported and that the preview will use a cloud-based sandbox. The fallback message is informative and correct. Raw Changes AnalyzedFile: bun.lock
Changes:
@@ -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",
@@ -66,7 +67,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 +76,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 +102,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 +1028,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=="],
@@ -1156,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=="],
@@ -1350,8 +1356,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 +1540,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 +2044,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 +2732,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=="],
File: convex/importData.ts
Changes:
@@ -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(),
File: convex/sandboxSessions.ts
Changes:
@@ -16,19 +16,22 @@ 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
+ 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,
File: convex/schema.ts
Changes:
@@ -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(
@@ -55,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"),
@@ -115,6 +128,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()),
})
@@ -255,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(),
@@ -265,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"]),
});
File: convex/usage.ts
Changes:
@@ -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
File: env.example
Changes:
@@ -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=""
File: explanations/EXPO_INTEGRATION.md
Changes:
@@ -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 (
+ <View style={styles.container}>
+ <Text style={styles.title}>Hello Expo</Text>
+ <TouchableOpacity style={styles.button}>
+ <Text style={styles.buttonText}>Press Me</Text>
+ </TouchableOpacity>
+ <StatusBar style="auto" />
+ </View>
+ );
+}
+
+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 `<input type="file" accept="image/*" capture>`
+- **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)
File: explanations/WEBCONTAINERS_MIGRATION.md
Changes:
@@ -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
File: package.json
Changes:
@@ -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",
@@ -73,7 +74,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 +83,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 +109,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",
File: sandbox-templates/expo-android/e2b.Dockerfile
Changes:
@@ -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"]
File: sandbox-templates/expo-android/e2b.toml
Changes:
@@ -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
File: sandbox-templates/expo-android/start_android.sh
Changes:
@@ -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
File: sandbox-templates/expo-full/e2b.Dockerfile
Changes:
@@ -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"]
File: sandbox-templates/expo-full/e2b.toml
Changes:
@@ -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
File: sandbox-templates/expo-web/e2b.Dockerfile
Changes:
@@ -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"]
File: sandbox-templates/expo-web/e2b.toml
Changes:
@@ -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
File: src/agents/code-agent.ts
Changes:
@@ -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<Framework> {
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;
}
File: src/agents/eas-build.ts
Changes:
@@ -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<void> {
+ 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<EASBuildResult> {
+ 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<EASBuildStatus> {
+ 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<EASBuildStatus> {
+ 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<string | null> {
+ 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<boolean> {
+ 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;
+ }
+}
File: src/agents/expo-qr.ts
Changes:
@@ -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<string> {
+ 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<boolean> {
+ 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;
+ }
+}
File: src/agents/runtime-selector.ts
Changes:
@@ -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,
+ };
+}
File: src/agents/sandbox-utils.ts
Changes:
@@ -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<string, Sandbox>();
const PROJECT_SANDBOX_MAP = new Map<string, string>();
@@ -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`;
};
File: src/agents/types.ts
Changes:
@@ -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];
}
File: src/agents/webcontainer-utils.ts
Changes:
@@ -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<void>;
+ read: (path: string) => Promise<string>;
+ list: (path?: string) => Promise<string[]>;
+ };
+ commands: {
+ run: (cmd: string, opts?: CommandOptions) => Promise<CommandResult>;
+ };
+ teardown: () => Promise<void>;
+ onServerReady?: (callback: (port: number, url: string) => void) => void;
+}
+
+export interface CommandOptions {
+ timeoutMs?: number;
+ background?: boolean;
+ cwd?: string;
+ env?: Record<string, string>;
+}
+
+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<typeof import("@webcontainer/api").WebContainer> | 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<SandboxInterface> {
+ 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<string, string>): 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<string, string>
+): Promise<void> {
+ 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<string> {
+ 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<void> {
+ 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<string | null> {
+ 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}`;
+}
File: src/app/api/expo/build/route.ts
Changes:
@@ -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 }
+ );
+ }
+}
File: src/components/ExpoPreviewSelector.tsx
Changes:
@@ -0,0 +1,182 @@
+'use client';
+
+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;
+ title: string;
+ description: string;
+ badge?: string;
+ buildTime: string;
+ tier: UserTier;
+ icon: string;
+ runtime: RuntimeType;
+}
+
+const PREVIEW_OPTIONS: PreviewOption[] = [
+ {
+ mode: 'web',
+ title: 'Web Preview',
+ description: 'Instant preview in browser via WebContainers',
+ buildTime: '~10 seconds',
+ tier: 'free',
+ icon: '🌐',
+ runtime: 'webcontainer'
+ },
+ {
+ mode: 'expo-go',
+ title: 'Expo Go (QR Code)',
+ description: 'Test on real device via Expo Go app',
+ buildTime: '~1-2 minutes',
+ tier: 'free',
+ icon: '📱',
+ runtime: 'e2b'
+ },
+ {
+ mode: 'android-emulator',
+ title: 'Android Emulator',
+ description: 'Full Android emulator with VNC access',
+ badge: 'Pro',
+ buildTime: '~3-5 minutes',
+ tier: 'pro',
+ icon: '🤖',
+ runtime: 'e2b'
+ },
+ {
+ mode: 'eas-build',
+ title: 'EAS Build (Production)',
+ description: 'Cloud builds for App Store/Play Store',
+ badge: 'Pro',
+ buildTime: '~5-15 minutes',
+ tier: 'pro',
+ icon: '🚀',
+ runtime: 'e2b'
+ }
+];
+
+interface ExpoPreviewSelectorProps {
+ onSelect: (mode: ExpoPreviewMode, runtime: RuntimeType) => void;
+ userTier?: UserTier;
+ selectedMode?: ExpoPreviewMode;
+ className?: string;
+}
+
+export function ExpoPreviewSelector({
+ onSelect,
+ userTier = 'free',
+ selectedMode,
+ className
+}: ExpoPreviewSelectorProps) {
+ const [selected, setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web');
+ const [browserCapabilities, setBrowserCapabilities] = useState<BrowserCapabilities | null>(null);
+
+ useEffect(() => {
+ setBrowserCapabilities(checkWebContainerSupport());
+ }, []);
+
+ const handleSelect = (mode: ExpoPreviewMode) => {
+ const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
+ if (!option) return;
+
+ const tierOrder: Record<UserTier, number> = { 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);
+ const actualRuntime = webContainerUnavailable ? 'e2b' : option.runtime;
+ onSelect(mode, actualRuntime);
+ }
+ };
+
+ return (
+ <div className={cn('grid grid-cols-1 sm:grid-cols-2 gap-3', className)}>
+ {PREVIEW_OPTIONS.map((option) => {
+ const tierOrder: Record<UserTier, number> = { 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 (
+ <Card
+ key={option.mode}
+ className={cn(
+ 'cursor-pointer transition-all duration-200',
+ isSelected && 'ring-2 ring-primary bg-primary/5',
+ isLocked && 'opacity-60 cursor-not-allowed',
+ !isLocked && !isSelected && 'hover:bg-muted/50'
+ )}
+ onClick={() => handleSelect(option.mode)}
+ >
+ <CardContent className="p-4">
+ <div className="flex items-start justify-between mb-2">
+ <div className="flex items-center gap-2">
+ <span className="text-xl">{option.icon}</span>
+ <h4 className="font-semibold text-sm">{option.title}</h4>
+ </div>
+ <div className="flex gap-1">
+ {isWebContainer && webContainerSupported && (
+ <Badge variant="default" className="text-xs bg-green-600">
+ Instant
+ </Badge>
+ )}
+ {isWebContainer && !webContainerSupported && browserCapabilities && (
+ <Badge variant="outline" className="text-xs">
+ Cloud
+ </Badge>
+ )}
+ {option.badge && (
+ <Badge variant="secondary" className="text-xs">
+ {option.badge}
+ </Badge>
+ )}
+ {isLocked && (
+ <Badge variant="outline" className="text-xs">
+ 🔒
+ </Badge>
+ )}
+ </div>
+ </div>
+ <p className="text-xs text-muted-foreground mb-2">
+ {isWebContainer && !webContainerSupported && browserCapabilities
+ ? 'Preview via cloud sandbox (WebContainers unavailable)'
+ : option.description}
+ </p>
+ <p className="text-xs text-muted-foreground/70">
+ Build time: {option.buildTime}
+ </p>
+ </CardContent>
+ </Card>
+ );
+ })}
+ </div>
+ );
+}
+
+export function ExpoPreviewInfo({ mode }: { mode: ExpoPreviewMode }) {
+ const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
+ if (!option) return null;
+
+ return (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <span>{option.icon}</span>
+ <span>{option.title}</span>
+ <span className="text-xs">({option.buildTime})</span>
+ </div>
+ );
+}
+
+export { PREVIEW_OPTIONS };
File: src/lib/browser-capabilities.ts
Changes:
@@ -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,
+ };
+}
File: src/lib/frameworks.ts
Changes:
@@ -341,6 +341,73 @@ export const frameworks: Record<string, FrameworkData> = {
'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'
+ ]
}
};
File: src/prompt.ts
Changes:
@@ -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";
File: src/prompts/expo.ts
Changes:
@@ -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 <package> --yes" or "npx expo install <package>")
+- 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 <task_summary> 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 <package>)
+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:
+
+<task_summary>
+A short, high-level summary of what was created or changed.
+</task_summary>
+`;
+
+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 (
+ <View style={styles.container}>
+ <Text style={styles.title}>Hello Expo</Text>
+ <TouchableOpacity style={styles.button} onPress={() => console.log('Pressed')}>
+ <Text style={styles.buttonText}>Press Me</Text>
+ </TouchableOpacity>
+ <StatusBar style="auto" />
+ </View>
+ );
+}
+
+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 <Stack />;
+}
+
+// app/index.tsx
+import { Link } from 'expo-router';
+import { View, Text } from 'react-native';
+
+export default function Home() {
+ return (
+ <View>
+ <Text>Home Screen</Text>
+ <Link href="/details">Go to Details</Link>
+ </View>
+ );
+}
+\`\`\`
+
+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 <package>)
+3. FINALLY: Provide <task_summary> 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 \`<input type="file" accept="image/*" capture>\`
+- 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]}
+`;
File: src/prompts/framework-selector.ts
Changes:
@@ -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.
`;
File: src/proxy.ts
Changes:
@@ -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)(.*)",
],
};
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. 📝 WalkthroughWalkthroughAdds first-class Expo support: new framework enum and preview modes, WebContainer ↔ E2B runtime selection, EAS build and QR integrations, Expo sandbox templates, schema additions, prompts, UI selector, browser/runtime tooling, API routes, and package/env updates. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Server as Server
participant RuntimeSelector as RuntimeSelector
participant WebContainer as WebContainer
participant E2B as E2B
participant ExpoAPI as ExpoAPI
Client->>Server: Request preview/build (framework, expoPreviewMode)
Server->>RuntimeSelector: selectRuntime(framework, taskType, expoPreviewMode, browserCaps)
alt Use WebContainers
RuntimeSelector->>WebContainer: startWebContainerDevServer(framework, expoPreviewMode)
WebContainer-->>Client: Dev server URL / ready
else Use E2B
RuntimeSelector->>E2B: provision sandbox(template, runtimeType)
E2B-->>Client: Sandbox URL / VNC / QR
end
Note over Client,Server: Native build via EAS (if requested)
Client->>Server: POST /api/expo/build (platform, profile, projectId)
Server->>ExpoAPI: trigger EAS build (uses EXPO_ACCESS_TOKEN)
ExpoAPI-->>Server: buildId (queued)
Server-->>Client: queued response (buildId)
loop Poll
Server->>ExpoAPI: GET build status
ExpoAPI-->>Server: status / artifacts
end
Server-->>Client: final status & artifact URLs
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, { | ||
| headers: { | ||
| Authorization: `Bearer ${expoToken}`, | ||
| Accept: "application/json", | ||
| }, | ||
| }); |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 29 days ago
In general, to fix this type of issue, you restrict or validate the user-controlled portion of the URL such that it cannot cause the request to be redirected to unintended endpoints or contain dangerous characters. The safest pattern is to either (a) map user inputs to a small allow‑list of known safe values, or (b) strictly validate the user input with an allow‑list style check (e.g., a regular expression) that only permits the exact format you expect.
For this specific code, the host is already fixed to https://api.expo.dev, so we only need to harden the buildId path segment. The best minimal‑impact fix is to validate buildId against a conservative regular expression corresponding to the IDs Expo actually uses (e.g., alphanumeric, dashes, underscores, and limited length), reject values that don’t match, and only use the validated value when constructing the URL. This preserves existing behavior for legitimate build IDs while preventing odd characters like slashes, query delimiters (?, #), or other control characters that could lead to unforeseen API behavior.
Concretely:
- In
src/app/api/expo/build/route.ts, after retrievingbuildIdand checking for null/empty, add a validation step with a regex, e.g./^[A-Za-z0-9_-]{1,128}$/. - If
buildIdfails validation, return a400response with a clear error message. - Then, use the validated
buildIdwhen building the URL (the same variable is fine once validated). - No new imports are strictly necessary; we can use the built‑in
RegExpsupport.
This confines the user-controlled data to a safe subset and should satisfy CodeQL’s concern because the tainted data is cleaned via an allow‑list before being used in the URL path.
| @@ -103,6 +103,16 @@ | ||
| ); | ||
| } | ||
|
|
||
| // Allow-list validation for buildId to prevent malformed or dangerous values | ||
| // Adjust the pattern if your Expo build IDs use a different format. | ||
| const buildIdPattern = /^[A-Za-z0-9_-]{1,128}$/; | ||
| if (!buildIdPattern.test(buildId)) { | ||
| return NextResponse.json( | ||
| { error: "Invalid buildId format" }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const expoToken = process.env.EXPO_ACCESS_TOKEN; | ||
| if (!expoToken) { | ||
| return NextResponse.json( |
|
🚀 Launching Scrapybara desktop... |
|
❌ Something went wrong: |
Deployment failedThis pull request failed while building automatically on Stormkit. You can preview the logs using the following link. |
|
|
||
| # Start Android emulator | ||
| echo "[INFO] Starting Android emulator..." | ||
| $ANDROID_HOME/emulator/emulator -avd expo_emulator \ |
Check notice
Code scanning / Shellcheck (reported by Codacy)
Double quote to prevent globbing and word splitting. Note
| echo "[INFO] Emulator ready!" | ||
|
|
||
| # Start Expo Metro bundler with Android | ||
| cd /home/user |
Check notice
Code scanning / Shellcheck (reported by Codacy)
Use 'cd ... || exit' or 'cd ... || return' in case cd fails. Note
There was a problem hiding this comment.
Actionable comments posted: 16
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
convex/sandboxSessions.ts (1)
9-44: Add areturnsvalidator for the Convex mutation.
Convex functions must declare return validators;createreturns a sandbox session ID and is missing thereturnsproperty.Suggested fix
export const create = mutation({ args: { sandboxId: v.string(), projectId: v.id("projects"), userId: v.string(), framework: v.union( v.literal("NEXTJS"), v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), v.literal("SVELTE"), v.literal("EXPO") ), runtimeType: v.optional(v.union(v.literal("webcontainer"), v.literal("e2b"))), autoPauseTimeout: v.optional(v.number()), }, + returns: v.id("sandboxSessions"), handler: async (ctx, args) => {src/agents/sandbox-utils.ts (2)
444-458:getSandboxUrldoesn't supportexpoPreviewMode, causing incorrect port for Expo previews.The
getSandboxUrlfunction callsgetFrameworkPort(framework)without passingexpoPreviewMode. When the framework is "expo", this will always return port 8081 regardless of the actual preview mode (e.g., android-emulator needs 5900).🐛 Proposed fix
- export async function getSandboxUrl(sandbox: Sandbox, framework: Framework): Promise<string> { - const port = getFrameworkPort(framework); + export async function getSandboxUrl( + sandbox: Sandbox, + framework: Framework, + expoPreviewMode?: ExpoPreviewMode + ): Promise<string> { + const port = getFrameworkPort(framework, expoPreviewMode);
460-497:startDevServerdoesn't supportexpoPreviewMode, breaking Expo preview functionality.Similar to
getSandboxUrl,startDevServercallsgetFrameworkPortandgetDevServerCommandwithoutexpoPreviewMode. This means Expo sandboxes will always use the default web preview command and port instead of the appropriate one for the selected preview mode.🐛 Proposed fix
- export async function startDevServer(sandbox: Sandbox, framework: Framework): Promise<string> { - const port = getFrameworkPort(framework); - const devCommand = getDevServerCommand(framework); + export async function startDevServer( + sandbox: Sandbox, + framework: Framework, + expoPreviewMode?: ExpoPreviewMode + ): Promise<string> { + const port = getFrameworkPort(framework, expoPreviewMode); + const devCommand = getDevServerCommand(framework, expoPreviewMode); // ... rest of function - return getSandboxUrl(sandbox, framework); + return getSandboxUrl(sandbox, framework, expoPreviewMode);
🤖 Fix all issues with AI agents
In `@convex/sandboxSessions.ts`:
- Line 27: The defaulting for autoPauseTimeout uses the || operator which treats
0 as falsy; update the assignment for autoPauseTimeout to use the nullish
coalescing operator so a caller can pass 0 to disable auto‑pause (change the
expression using args.autoPauseTimeout to use ?? instead of ||); locate the
variable autoPauseTimeout and the use of args.autoPauseTimeout in the
sandboxSessions.ts function and replace the fallback logic accordingly.
In `@explanations/EXPO_INTEGRATION.md`:
- Around line 165-171: The markdown contains a bare URL causing MD034; update
the README section that documents EXPO_ACCESS_TOKEN so the URL is wrapped in
Markdown link syntax or angle brackets (e.g., replace the plain
"https://expo.dev/settings/access-tokens" with "[Expo access
tokens](https://expo.dev/settings/access-tokens)" or
"<https://expo.dev/settings/access-tokens>"). Locate the paragraph referencing
EXPO_ACCESS_TOKEN and update that URL only, preserving the surrounding text and
code block.
In `@explanations/WEBCONTAINERS_MIGRATION.md`:
- Around line 9-27: Add a language identifier to the fenced code block
containing the ASCII diagram so markdown linters render it consistently; change
the opening fence from ``` to ```text (i.e., update the code block that begins
with the ASCII "User Request" diagram in WEBCONTAINERS_MIGRATION.md) and leave
the closing ``` unchanged.
- Around line 59-64: Update the "Supported browsers" list under the "Supported
browsers:" section: change the Chrome/Chromium entry from "Chrome 68+" to a
modern minimum (e.g., "Chrome 160+" or "modern Chrome 160+") to reflect
WebContainers requirements, keep Safari as "Safari: Beta support (16.4+)"
unchanged, and remove the specific Firefox version (replace "Firefox: Beta
support (79+)" with "Firefox: Beta support (no specific minimum documented)").
Edit the list items for Chrome and Firefox in the block starting with the
"Supported browsers:" header to reflect these changes.
In `@sandbox-templates/expo-android/start_android.sh`:
- Around line 45-47: The script currently runs "cd /home/user" then continues
regardless; update start_android.sh to check the result of the cd command and
abort with an error message if it fails (e.g., test exit status of cd or use a
conditional like "cd /home/user || { echo ...; exit 1; }") so that "npx expo
start --android --port 8081 --host 0.0.0.0" never runs from the wrong directory;
ensure the error message clearly states the failed directory change before
exiting.
In `@src/agents/eas-build.ts`:
- Around line 110-118: The build command currently embeds EXPO_TOKEN in the
command string (buildCommand); instead, remove EXPO_TOKEN from buildCommand and
pass the token via an environment map when invoking runCodeCommand (e.g., add an
env param to runCodeCommand and call runCodeCommand(sandbox, buildCommand, {
env: { EXPO_TOKEN: expoToken } })). Update runCodeCommand in sandbox-utils.ts to
accept and merge an env object with the sandbox/default environment and ensure
it uses that merged env when spawning the process or invoking the sandbox
execution API; keep error handling the same but do not expose the token in the
command string or logs.
- Around line 94-142: The triggerEASBuild function lacks retry logic so any EAS
CLI failure immediately throws; wrap the runCodeCommand + parse block in a retry
loop that attempts the build up to 2 retries (3 total attempts) on failures,
logging the error context and attempt number (use runCodeCommand
result.stderr/stdout) before each retry, and backoff briefly between attempts;
keep initializeEAS and EXPO token checks unchanged, and ensure the function only
throws after all retries fail, returning the parsed build object on a successful
attempt.
In `@src/agents/expo-qr.ts`:
- Around line 51-61: The getEASUpdateQRUrl function builds a query string but
only encodes runtimeVersion; update it to encode all interpolated query values
(projectId and channel as well) using encodeURIComponent so special characters
do not break the URL; locate getEASUpdateQRUrl and replace the direct
interpolations of projectId and channel with their encoded equivalents (similar
to how runtimeVersion is handled) while preserving the existing runtimeVersion
encoding and return behavior.
In `@src/agents/types.ts`:
- Around line 3-5: Keep the ExpoPreviewMode definition in src/agents/types.ts as
the single source of truth (the exported type ExpoPreviewMode) and remove
duplicate type declarations elsewhere; update
src/components/ExpoPreviewSelector.tsx to import that type (import type {
ExpoPreviewMode } from '@/agents/types') instead of redefining it, and for
convex/usage.ts either import the type from a shared module or add a single
re-export from a shared entry (e.g., export type { ExpoPreviewMode } from
'@/agents/types') so Convex-consumable code uses the same enum/type rather than
redefining it.
In `@src/agents/webcontainer-utils.ts`:
- Around line 235-239: Duplicate branches inside the switch case for "expo"
return the same command; remove the redundant if/else and simplify the "expo"
case to a single return of "npx expo start --web --port 8081" (leave any
surrounding logic or the expoPreviewMode variable intact) so the switch's case
"expo" only returns the command once.
- Around line 299-316: The code incorrectly treats exitCode === 127 as "script
not found"; update runWebContainerBuildCheck to not assume 127 means a missing
npm script: combine result.stdout and result.stderr into a single output string
and check that string for npm's "missing script" message (e.g. /missing script/i
or "npm ERR! missing script") to decide whether to skip; if exitCode === 127 but
the output does not indicate a missing script, treat it as a command-not-found
error (log a warning including the output) and return a failure message; ensure
all references use result.exitCode, result.stdout, result.stderr and
sandbox.commands.run so the new checks are applied in the
runWebContainerBuildCheck function.
- Around line 112-142: The run function currently reads from webcontainer
process.output into stdout but leaves stderr empty; fix by treating
WebContainer's process.output as a combined stream and set stderr to the same
combined text (or explicitly document the combined behavior). In the run
implementation (run, webcontainerInstance, process.output), when accumulating
chunks into stdout also assign the same combined string to stderr before
returning so callers receive the full output in both fields (or add a clear
comment near run/CommandOptions explaining that stdout contains combined
stdout+stderr).
- Around line 117-124: The implementation currently ignores
CommandOptions.timeoutMs after calling webcontainerInstance!.spawn; add a
timeout race after spawning: if opts?.timeoutMs is set, start a timer that, when
elapsed, terminates the spawned process (via the process object's kill/terminate
API or sending an exit signal) and resolves/returns the same result shape with
an appropriate non-zero exitCode and stderr indicating a timeout; otherwise
continue waiting for the normal process completion. Ensure you reference the
spawned process returned from webcontainerInstance!.spawn, check
opts?.background (keep the existing early-return), and clear the timer on normal
process completion to avoid leaks.
In `@src/app/api/expo/build/route.ts`:
- Around line 115-120: Validate the incoming buildId before interpolating it
into the Expo API URL to prevent SSRF: in the request handler that calls
fetch(`https://api.expo.dev/v2/builds/${buildId}`, ...) (the code using buildId
in route.ts), add a check that buildId strictly matches the UUID v4 pattern
(e.g., regex
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) and
return a 400/invalid response if it fails; only proceed to call fetch when the
validation passes. Ensure the validation runs before any use of buildId in the
URL and include the validation logic next to where buildId is extracted/parsed
in the handler.
In `@src/components/ExpoPreviewSelector.tsx`:
- Around line 9-11: Remove the duplicate ExpoPreviewMode type definition in
ExpoPreviewSelector.tsx and import the canonical type from your agents types
module instead: replace the local "ExpoPreviewMode" declaration with an import
from '@/agents/types' (and if external consumers need it, re-export via "export
type { ExpoPreviewMode } from '@/agents/types';"); leave UserTier and
RuntimeType as local types only if they are not defined elsewhere. Ensure you
reference the symbols ExpoPreviewMode, UserTier, and RuntimeType in the file to
verify the correct import/re-export is used.
In `@src/prompts/expo.ts`:
- Around line 12-16: Update the "File Safety Rules" block in src/prompts/expo.ts
to remove the contradiction: change the line that currently tells the LLM to
"use the actual path (e.g. '/home/user/components/Button.tsx')" so it instead
instructs that absolute system paths (like '/home/user/...') must NOT be used in
generated code or file tool calls and that absolute paths are only allowed when
explicitly required for readFiles or runtime file-system access; reference the
existing rule titles "File Safety Rules" and the term "readFiles" so the text
consistently mandates relative paths for generated code and clarifies when real
absolute file-system paths are permitted.
🧹 Nitpick comments (18)
src/agents/expo-qr.ts (1)
68-71: Add error handling for invalid URLs.
new URL(sandboxUrl)will throw aTypeErrorifsandboxUrlis not a valid URL. SincegenerateExpoGoQRhas a try/catch wrapper, consider adding similar protection here or documenting that callers must handle the exception.♻️ Option: Add defensive error handling
export function getExpoGoDeepLink(sandboxUrl: string): string { - const url = new URL(sandboxUrl); - return `exp://${url.host}`; + try { + const url = new URL(sandboxUrl); + return `exp://${url.host}`; + } catch { + throw new Error(`Invalid sandbox URL: ${sandboxUrl}`); + } }sandbox-templates/expo-web/e2b.Dockerfile (2)
17-17: Remove redundant WORKDIR directive.
WORKDIR /home/useris already set at line 6 and hasn't changed. This line is unnecessary.♻️ Proposed fix
-WORKDIR /home/user - # Start Metro bundler for web on port 8081
19-20: Clarify CMD vs start_cmd in e2b.toml.The Dockerfile
CMDspecifiesnpx expo start --web --port 8081 --host 0.0.0.0, whilee2b.tomlhas the same command instart_cmd. E2B'sstart_cmdtypically overrides the DockerCMD. If that's the case, thisCMDis redundant and could be removed for clarity—or kept as a fallback for local Docker testing.Consider adding a comment clarifying the intended behavior.
sandbox-templates/expo-full/e2b.Dockerfile (2)
1-4: Confirm the E2B template is built/published manually.
CI doesn’t auto-generate sandbox templates; please ensure this image was built and pushed via the manual E2B workflow.Based on learnings, templates require a manual build step.
9-18: Make Expo tool versions configurable and verify Node 21 support.
Unpinned@latestinstalls can make the image non‑reproducible; consider parameterizing versions so you can pin them, and confirm Expo CLI/EAS support Node 21 in the target runtime.♻️ Suggested change (version args)
-FROM node:21-slim +FROM node:21-slim +ARG CREATE_EXPO_APP_VERSION=latest +ARG EXPO_CLI_VERSION=latest +ARG EAS_CLI_VERSION=latest @@ -RUN npx create-expo-app@latest . --template blank-typescript --yes +RUN npx create-expo-app@${CREATE_EXPO_APP_VERSION} . --template blank-typescript --yes @@ -RUN npm install -g `@expo/cli` eas-cli +RUN npm install -g `@expo/cli`@${EXPO_CLI_VERSION} eas-cli@${EAS_CLI_VERSION}sandbox-templates/expo-android/e2b.Dockerfile (1)
14-47: Parameterize Node/Expo tool versions and confirm compatibility.
Unpinned installs make the template non‑reproducible; consider version args and confirm Expo/EAS support the chosen Node major and Android SDK level in E2B.♻️ Suggested change (version args)
-# Install Node.js 21 -RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - \ +# Install Node.js (parameterized) +ARG NODE_MAJOR=21 +RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ && apt-get install -y nodejs @@ -# Create Expo project -RUN npx create-expo-app@latest . --template blank-typescript --yes +# Create Expo project +ARG CREATE_EXPO_APP_VERSION=latest +RUN npx create-expo-app@${CREATE_EXPO_APP_VERSION} . --template blank-typescript --yes @@ -# Install global tools -RUN npm install -g `@expo/cli` eas-cli +# Install global tools +ARG EXPO_CLI_VERSION=latest +ARG EAS_CLI_VERSION=latest +RUN npm install -g `@expo/cli`@${EXPO_CLI_VERSION} eas-cli@${EAS_CLI_VERSION}sandbox-templates/expo-android/start_android.sh (1)
36-42: Consider adding a timeout to the boot wait loop.If the emulator fails to boot properly, this loop will wait indefinitely. Adding a timeout would make the script more resilient.
Optional improvement with timeout
# Wait for boot completion echo "[INFO] Waiting for boot completion..." +BOOT_TIMEOUT=300 # 5 minutes +BOOT_START=$(date +%s) while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do + ELAPSED=$(($(date +%s) - BOOT_START)) + if [[ $ELAPSED -gt $BOOT_TIMEOUT ]]; then + echo "[ERROR] Emulator boot timed out after ${BOOT_TIMEOUT}s" + exit 1 + fi sleep 2 donesrc/prompts/expo.ts (1)
220-221: Fragile string splitting for content reuse.Using
EXPO_PROMPT.split('Workflow:')[1]is brittle—if "Workflow:" is renamed or duplicated, this breaks silently. Consider extracting the shared workflow section into a separate constant.Suggested approach
// Extract shared content const EXPO_WORKFLOW_SECTION = ` Workflow: 1. FIRST: Generate all code files using createOrUpdateFiles 2. THEN: Use terminal to install packages if needed (npx expo install <package>) 3. FINALLY: Provide <task_summary> describing what you built Preview Modes: - **web**: Fast preview using react-native-web, limited native features ... `; // Then use in prompts: export const EXPO_WEB_PROMPT = ` ... ${EXPO_WORKFLOW_SECTION} `;src/app/api/expo/build/route.ts (1)
34-38: Avoid type assertion; use proper typing or runtime validation.The
as Id<"fragments">assertion bypasses TypeScript's type checking. Per coding guidelines, resolve types properly instead of usingasassertions.♻️ Proposed fix using Convex's Id type with validation
+ import { Id } from "@/convex/_generated/dataModel"; + + function isValidFragmentId(id: unknown): id is Id<"fragments"> { + return typeof id === "string" && id.length > 0; + } + const convex = getConvexClient(); + const validFragmentId = fragmentId && isValidFragmentId(fragmentId) ? fragmentId : null; const fragment = fragmentId ? await convex.query(api.messages.getFragmentById, { - fragmentId: fragmentId as Id<"fragments">, + fragmentId: validFragmentId!, }) : null;Alternatively, add a schema validator (e.g., Zod) to validate the entire request body with proper types.
src/agents/sandbox-utils.ts (1)
310-323: Expo template names should be defined intypes.tsunderFRAMEWORK_TEMPLATES.Per coding guidelines, E2B sandbox template names should be configured in
src/agents/types.tsunderFRAMEWORK_TEMPLATESrather than hardcoded here. This centralizes template configuration and makes it easier to maintain.Consider defining these in
types.ts:export const FRAMEWORK_TEMPLATES = { nextjs: "zapdev", angular: "zapdev-angular", // ... expo: { web: "zapdev-expo-web", "expo-go": "zapdev-expo-full", "android-emulator": "zapdev-expo-android", }, } as const;Based on learnings, E2B sandbox templates must be configured with names defined in
src/agents/types.tsunderFRAMEWORK_TEMPLATES.src/components/ExpoPreviewSelector.tsx (2)
89-90: Extract duplicatetierOrderconstant.The
tierOrderobject is defined identically in bothhandleSelectand the render function. Extract it to component scope or module scope:♻️ Proposed fix
+ const TIER_ORDER: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 }; + export function ExpoPreviewSelector({ // ... }) { // ... const handleSelect = (mode: ExpoPreviewMode) => { const option = PREVIEW_OPTIONS.find(o => o.mode === mode); if (!option) return; - const tierOrder: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 }; - const isLocked = tierOrder[userTier] < tierOrder[option.tier]; + const isLocked = TIER_ORDER[userTier] < TIER_ORDER[option.tier]; // ... }; return ( <div className={cn('grid grid-cols-1 sm:grid-cols-2 gap-3', className)}> {PREVIEW_OPTIONS.map((option) => { - const tierOrder: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 }; - const isLocked = tierOrder[userTier] < tierOrder[option.tier]; + const isLocked = TIER_ORDER[userTier] < TIER_ORDER[option.tier];Also applies to: 107-108
24-63: Consider usinglucide-reacticons per coding guidelines.The preview options use emoji strings for icons. Per coding guidelines, prefer
lucide-reactwithsize-4default:import { Globe, Smartphone, Monitor, Rocket } from 'lucide-react'; const PREVIEW_OPTIONS: PreviewOption[] = [ { mode: 'web', title: 'Web Preview', icon: Globe, // Component reference instead of emoji // ... }, // Smartphone for expo-go, Monitor for android-emulator, Rocket for eas-build ]; // In render: <option.icon className="size-4 text-muted-foreground" />This maintains visual consistency with the rest of the application's icon system.
src/lib/browser-capabilities.ts (1)
18-23:webContainerAPIis declared but never set totrue.The
webContainerAPIfield is initialized tofalseand never updated, making it effectively dead code. Either implement the actual check (e.g., checking for WebContainer API availability) or remove the field.♻️ Option 1: Remove unused field
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, };♻️ Option 2: Implement actual WebContainer API check
const capabilities: BrowserCapabilities = { sharedArrayBuffer: typeof SharedArrayBuffer !== "undefined", crossOriginIsolated: window.crossOriginIsolated ?? false, - webContainerAPI: false, + webContainerAPI: typeof window !== "undefined" && "WebContainer" in window, isSupported: false, };src/agents/runtime-selector.ts (1)
126-135: Logic for non-expo frameworks always defaults to preview.The condition
isPreview || framework !== "expo"means non-expo frameworks always return "preview" regardless of the prompt content. ThepreviewIndicatorscheck becomes irrelevant for them.If this is intentional (non-expo frameworks don't need "full-dev" distinction), consider simplifying:
♻️ Suggested simplification
- const previewIndicators = [ - "preview", - "show me", - "display", - "render", - "view", - "see the", - ]; - - const isPreview = previewIndicators.some((indicator) => - lowerPrompt.includes(indicator) - ); - - if (isPreview || framework !== "expo") { + // Non-expo frameworks always use preview mode + // Expo uses full-dev only when no native build or preview indicators found + if (framework !== "expo") { return selectRuntime(framework, "preview", expoPreviewMode); } + + const previewIndicators = ["preview", "show me", "display", "render", "view", "see the"]; + const isPreview = previewIndicators.some((indicator) => lowerPrompt.includes(indicator)); + + if (isPreview) { + return selectRuntime(framework, "preview", expoPreviewMode); + } return selectRuntime(framework, "full-dev", expoPreviewMode);src/agents/eas-build.ts (2)
164-180: Add defensive JSON parsing.
response.json()can throw if the response body is malformed. Wrap in try-catch for better error messages.♻️ Proposed fix
if (!response.ok) { throw new Error(`Failed to fetch build status: ${response.status} ${response.statusText}`); } - const data = await response.json(); + let data; + try { + data = await response.json(); + } catch (parseError) { + throw new Error(`Failed to parse build status response: ${parseError instanceof Error ? parseError.message : String(parseError)}`); + } return { status: data.status,
186-212: Consider adding exponential backoff for polling.The fixed 10-second polling interval could cause rate limiting issues with the Expo API. Consider exponential backoff, especially for longer builds.
♻️ Exponential backoff example
export async function waitForEASBuild( buildId: string, expoToken?: string, maxWaitMs: number = 15 * 60 * 1000, // 15 minutes default - pollIntervalMs: number = 10000 // 10 seconds + initialPollIntervalMs: number = 10000 // 10 seconds initial ): Promise<EASBuildStatus> { const startTime = Date.now(); + let pollIntervalMs = initialPollIntervalMs; + const maxPollIntervalMs = 60000; // Cap at 1 minute while (Date.now() - startTime < maxWaitMs) { const status = await checkEASBuildStatus(buildId, expoToken); // ... existing status checks ... console.log(`[DEBUG] EAS build ${buildId} status: ${status.status}`); await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + // Exponential backoff with cap + pollIntervalMs = Math.min(pollIntervalMs * 1.5, maxPollIntervalMs); }src/agents/webcontainer-utils.ts (2)
1-3: Type duplication:RuntimeTypeis defined in multiple locations.This type is also exported from
src/components/ExpoPreviewSelector.tsx. Consider consolidating to a single source of truth insrc/agents/types.tsand re-exporting where needed.#!/bin/bash # Verify RuntimeType definitions across codebase rg -n "export type RuntimeType" --type ts
318-321: Consider returning the actual WebContainer URL.Returning
http://localhost:${port}is a placeholder. WebContainers typically provide a unique preview URL via theserver-readyevent. This function might mislead callers expecting the actual accessible URL.Consider either:
- Removing this function if it's not useful
- Tracking the actual URL from
server-readyand returning it- Renaming to
getExpectedDevServerPortand returning just the port
| 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; |
There was a problem hiding this comment.
Prefer ?? for the auto‑pause default.
If 0 is a valid value (e.g., disabling auto‑pause), || will override it.
♻️ Suggested fix
- const autoPauseTimeout = args.autoPauseTimeout || 10 * 60 * 1000;
+ const autoPauseTimeout = args.autoPauseTimeout ?? 10 * 60 * 1000;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const autoPauseTimeout = args.autoPauseTimeout || 10 * 60 * 1000; | |
| const autoPauseTimeout = args.autoPauseTimeout ?? 10 * 60 * 1000; |
🤖 Prompt for AI Agents
In `@convex/sandboxSessions.ts` at line 27, The defaulting for autoPauseTimeout
uses the || operator which treats 0 as falsy; update the assignment for
autoPauseTimeout to use the nullish coalescing operator so a caller can pass 0
to disable auto‑pause (change the expression using args.autoPauseTimeout to use
?? instead of ||); locate the variable autoPauseTimeout and the use of
args.autoPauseTimeout in the sandboxSessions.ts function and replace the
fallback logic accordingly.
| 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 | ||
|
|
There was a problem hiding this comment.
Fix bare URL (markdownlint MD034).
Wrap the access‑token URL in Markdown link syntax (or angle brackets).
✏️ Suggested fix
-Get your token from: https://expo.dev/settings/access-tokens
+Get your token from: [Expo access tokens](https://expo.dev/settings/access-tokens)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 | |
| For EAS Build support, add to `.env`: |
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)
170-170: Bare URL used
(MD034, no-bare-urls)
🤖 Prompt for AI Agents
In `@explanations/EXPO_INTEGRATION.md` around lines 165 - 171, The markdown
contains a bare URL causing MD034; update the README section that documents
EXPO_ACCESS_TOKEN so the URL is wrapped in Markdown link syntax or angle
brackets (e.g., replace the plain "https://expo.dev/settings/access-tokens" with
"[Expo access tokens](https://expo.dev/settings/access-tokens)" or
"<https://expo.dev/settings/access-tokens>"). Locate the paragraph referencing
EXPO_ACCESS_TOKEN and update that URL only, preserving the surrounding text and
code block.
| ``` | ||
| User Request | ||
| │ | ||
| ▼ | ||
| ┌─────────────────────────────────────┐ | ||
| │ Runtime Selector │ | ||
| │ (src/agents/runtime-selector.ts) │ | ||
| └─────────────────────────────────────┘ | ||
| │ │ | ||
| ▼ ▼ | ||
| ┌──────────────┐ ┌──────────────┐ | ||
| │ WebContainers│ │ E2B │ | ||
| │ (Browser) │ │ (Cloud) │ | ||
| │ │ │ │ | ||
| │ - Instant │ │ - Full Linux │ | ||
| │ - Zero cost │ │ - Native │ | ||
| │ - Web only │ │ builds │ | ||
| └──────────────┘ └──────────────┘ | ||
| ``` |
There was a problem hiding this comment.
Add language identifier to code block.
The fenced code block containing the ASCII diagram should specify a language identifier to comply with markdown linting rules and ensure consistent rendering across viewers.
📝 Proposed fix
-```
+```text
User Request
│
▼📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ``` | |
| User Request | |
| │ | |
| ▼ | |
| ┌─────────────────────────────────────┐ | |
| │ Runtime Selector │ | |
| │ (src/agents/runtime-selector.ts) │ | |
| └─────────────────────────────────────┘ | |
| │ │ | |
| ▼ ▼ | |
| ┌──────────────┐ ┌──────────────┐ | |
| │ WebContainers│ │ E2B │ | |
| │ (Browser) │ │ (Cloud) │ | |
| │ │ │ │ | |
| │ - Instant │ │ - Full Linux │ | |
| │ - Zero cost │ │ - Native │ | |
| │ - Web only │ │ builds │ | |
| └──────────────┘ └──────────────┘ | |
| ``` |
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)
9-9: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
In `@explanations/WEBCONTAINERS_MIGRATION.md` around lines 9 - 27, Add a language
identifier to the fenced code block containing the ASCII diagram so markdown
linters render it consistently; change the opening fence from ``` to ```text
(i.e., update the code block that begins with the ASCII "User Request" diagram
in WEBCONTAINERS_MIGRATION.md) and leave the closing ``` unchanged.
| Supported browsers: | ||
| - Chrome/Chromium: Full support (Chrome 68+) | ||
| - Edge: Full support | ||
| - Safari: Beta support (16.4+) | ||
| - Firefox: Beta support (79+) | ||
| - Mobile: Limited support |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
StackBlitz WebContainers browser requirements SharedArrayBuffer support
💡 Result:
Short answer — WebContainers require SharedArrayBuffer which only works in a cross-origin-isolated page. Practically that means:
- Browser/platform: best on Chrome and Chromium forks (full support); Firefox and Safari have “beta”/partial support. Mobile has limited/beta support. [1][2]
- Required headers for cross-origin isolation: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or the newer COEP: credentialless workflow when available). Without cross-origin isolation SAB is unavailable and WebContainers won’t run. [2][3]
- Embeds: embedding makes isolation harder — credentialless COEP (available in Chromium) is the long‑term solution; other browsers may block third‑party resources in embeds. If you embed, prefer Chromium or open previews in a new window. [1][2][3]
Sources: StackBlitz WebContainers browser support/docs and engineering blog posts. [1][2][3]
Sources:
[1] StackBlitz WebContainers — Browser Support (docs). (developer.stackblitz.com)
[2] StackBlitz engineering blog — Cross‑Browser support with COOP/COEP (explains SharedArrayBuffer + headers). (blog.stackblitz.com)
[3] StackBlitz blog & posts about COEP credentialless and cross‑browser progress. (blog.stackblitz.com)
Citations:
- 1: https://developer.stackblitz.com/platform/webcontainers/browser-support?utm_source=openai
- 2: https://blog.stackblitz.com/posts/cross-browser-with-coop-coep/?utm_source=openai
- 3: https://blog.stackblitz.com/posts/bringing-webcontainers-to-all-browsers/?utm_source=openai
🌐 Web query:
WebContainers minimum browser version Chrome 68 Safari Firefox
💡 Result:
Short answer:
- WebContainers require a modern Chromium-based browser (recent Chrome / Edge / Brave). StackBlitz doesn’t publish a single minimum Chrome version; in practice you need a fairly recent Chrome (Chrome 16x+ / fully supported — see notes). [1][3]
- Safari: beta support starting Safari 16.4 (and iOS/iPadOS 16.4+). [1][3]
- Firefox: only beta/partial support (may have limitations). No single minimum Firefox version is specified; use a recent Firefox build. [1][3]
Notes: WebContainers depend on modern Web Platform features (SharedArrayBuffer + cross-origin isolation, Atomics.waitAsync, lookbehind RegExp), so older browser releases (e.g., Chrome 68) will not work. See StackBlitz/WebContainers browser support for details. [1][3]
Sources:
[1] StackBlitz WebContainers — Browser Support.
[2] StackBlitz blog: WebContainers on Safari.
[3] WebContainers (webcontainers.io) — Browser Support.
Update browser version requirements to match official WebContainers specifications.
Chrome 68+ is outdated and will not work with WebContainers—the platform requires modern Web APIs (SharedArrayBuffer, Atomics.waitAsync, lookbehind RegExp) only available in significantly newer Chrome versions (160+ for full support). Safari 16.4+ is correct, but Firefox has no specific minimum version documented; the file should reflect beta support without a specific version requirement.
🤖 Prompt for AI Agents
In `@explanations/WEBCONTAINERS_MIGRATION.md` around lines 59 - 64, Update the
"Supported browsers" list under the "Supported browsers:" section: change the
Chrome/Chromium entry from "Chrome 68+" to a modern minimum (e.g., "Chrome 160+"
or "modern Chrome 160+") to reflect WebContainers requirements, keep Safari as
"Safari: Beta support (16.4+)" unchanged, and remove the specific Firefox
version (replace "Firefox: Beta support (79+)" with "Firefox: Beta support (no
specific minimum documented)"). Edit the list items for Chrome and Firefox in
the block starting with the "Supported browsers:" header to reflect these
changes.
| cd /home/user | ||
| echo "[INFO] Starting Expo development server..." | ||
| npx expo start --android --port 8081 --host 0.0.0.0 |
There was a problem hiding this comment.
Handle cd failure to prevent running Expo from an unexpected directory.
If /home/user doesn't exist or is inaccessible, the script will continue and run npx expo start from the wrong directory, potentially causing silent failures or unexpected behavior.
Proposed fix
-cd /home/user
+cd /home/user || { echo "[ERROR] Failed to change to /home/user"; exit 1; }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| cd /home/user | |
| echo "[INFO] Starting Expo development server..." | |
| npx expo start --android --port 8081 --host 0.0.0.0 | |
| cd /home/user || { echo "[ERROR] Failed to change to /home/user"; exit 1; } | |
| echo "[INFO] Starting Expo development server..." | |
| npx expo start --android --port 8081 --host 0.0.0.0 |
🧰 Tools
🪛 Shellcheck (0.11.0)
[warning] 45-45: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
(SC2164)
🤖 Prompt for AI Agents
In `@sandbox-templates/expo-android/start_android.sh` around lines 45 - 47, The
script currently runs "cd /home/user" then continues regardless; update
start_android.sh to check the result of the cd command and abort with an error
message if it fails (e.g., test exit status of cd or use a conditional like "cd
/home/user || { echo ...; exit 1; }") so that "npx expo start --android --port
8081 --host 0.0.0.0" never runs from the wrong directory; ensure the error
message clearly states the failed directory change before exiting.
| case "expo": | ||
| if (expoPreviewMode === "web" || !expoPreviewMode) { | ||
| return "npx expo start --web --port 8081"; | ||
| } | ||
| return "npx expo start --web --port 8081"; |
There was a problem hiding this comment.
Duplicate branches return identical command for Expo.
Both the if branch (line 237) and the fallback (line 239) return the same command. Remove the redundant condition.
🧹 Proposed fix
case "expo":
- if (expoPreviewMode === "web" || !expoPreviewMode) {
- return "npx expo start --web --port 8081";
- }
+ // Expo web preview - other modes (expo-go, android-emulator) use E2B
return "npx expo start --web --port 8081";📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| case "expo": | |
| if (expoPreviewMode === "web" || !expoPreviewMode) { | |
| return "npx expo start --web --port 8081"; | |
| } | |
| return "npx expo start --web --port 8081"; | |
| case "expo": | |
| // Expo web preview - other modes (expo-go, android-emulator) use E2B | |
| return "npx expo start --web --port 8081"; |
🤖 Prompt for AI Agents
In `@src/agents/webcontainer-utils.ts` around lines 235 - 239, Duplicate branches
inside the switch case for "expo" return the same command; remove the redundant
if/else and simplify the "expo" case to a single return of "npx expo start --web
--port 8081" (leave any surrounding logic or the expoPreviewMode variable
intact) so the switch's case "expo" only returns the command once.
| const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, { | ||
| headers: { | ||
| Authorization: `Bearer ${expoToken}`, | ||
| Accept: "application/json", | ||
| }, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/app/api/expo/build/route.ts | head -150Repository: Jackson57279/zapdev
Length of output: 182
🌐 Web query:
Expo EAS build ID format specification
💡 Result:
Expo EAS build IDs are UUIDs (UUID v4). Example: f51831f0-ea30-406a-8c5f-f8e1cc57d39c. The canonical pattern is 8-4-4-4-12 hex characters; a strict v4 regex:
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.
Sources: Expo docs (environment variables showing EAS_BUILD_ID example). [1][2]
References
[1] docs.expo.dev — Environment variables in EAS.
[2] expo.nodejs.cn — EAS 中的环境变量 (Chinese mirror of Expo docs).
Server-Side Request Forgery (SSRF) vulnerability: validate buildId before using in URL.
The buildId parameter is taken directly from user input and interpolated into the Expo API URL without validation. A malicious user could potentially manipulate this to probe internal services or cause unintended requests.
Add validation to ensure buildId matches the Expo EAS build ID format (UUID v4):
🔒 Proposed fix
+ const EXPO_BUILD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
if (!buildId) {
return NextResponse.json(
{ error: "buildId query parameter is required" },
{ status: 400 }
);
}
+ if (!EXPO_BUILD_ID_PATTERN.test(buildId)) {
+ return NextResponse.json(
+ { error: "Invalid buildId format" },
+ { status: 400 }
+ );
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, { | |
| headers: { | |
| Authorization: `Bearer ${expoToken}`, | |
| Accept: "application/json", | |
| }, | |
| }); | |
| const EXPO_BUILD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; | |
| if (!buildId) { | |
| return NextResponse.json( | |
| { error: "buildId query parameter is required" }, | |
| { status: 400 } | |
| ); | |
| } | |
| if (!EXPO_BUILD_ID_PATTERN.test(buildId)) { | |
| return NextResponse.json( | |
| { error: "Invalid buildId format" }, | |
| { status: 400 } | |
| ); | |
| } | |
| const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, { | |
| headers: { | |
| Authorization: `Bearer ${expoToken}`, | |
| Accept: "application/json", | |
| }, | |
| }); |
🧰 Tools
🪛 GitHub Check: CodeQL
[failure] 115-120: Server-side request forgery
The URL of this request depends on a user-provided value.
🤖 Prompt for AI Agents
In `@src/app/api/expo/build/route.ts` around lines 115 - 120, Validate the
incoming buildId before interpolating it into the Expo API URL to prevent SSRF:
in the request handler that calls
fetch(`https://api.expo.dev/v2/builds/${buildId}`, ...) (the code using buildId
in route.ts), add a check that buildId strictly matches the UUID v4 pattern
(e.g., regex
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) and
return a 400/invalid response if it fails; only proceed to call fetch when the
validation passes. Ensure the validation runs before any use of buildId in the
URL and include the validation logic next to where buildId is extracted/parsed
in the handler.
| export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build'; | ||
| export type UserTier = 'free' | 'pro' | 'enterprise'; | ||
| export type RuntimeType = 'webcontainer' | 'e2b'; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Types should be imported from canonical source.
As noted earlier, ExpoPreviewMode is defined in src/agents/types.ts. Import it rather than redefining:
- export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build';
- export type UserTier = 'free' | 'pro' | 'enterprise';
- export type RuntimeType = 'webcontainer' | 'e2b';
+ import type { ExpoPreviewMode } from '@/agents/types';
+
+ export type UserTier = 'free' | 'pro' | 'enterprise';
+ export type RuntimeType = 'webcontainer' | 'e2b';If you need to re-export for external consumers, use export type { ExpoPreviewMode } from '@/agents/types';
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build'; | |
| export type UserTier = 'free' | 'pro' | 'enterprise'; | |
| export type RuntimeType = 'webcontainer' | 'e2b'; | |
| import type { ExpoPreviewMode } from '@/agents/types'; | |
| export type UserTier = 'free' | 'pro' | 'enterprise'; | |
| export type RuntimeType = 'webcontainer' | 'e2b'; |
🤖 Prompt for AI Agents
In `@src/components/ExpoPreviewSelector.tsx` around lines 9 - 11, Remove the
duplicate ExpoPreviewMode type definition in ExpoPreviewSelector.tsx and import
the canonical type from your agents types module instead: replace the local
"ExpoPreviewMode" declaration with an import from '@/agents/types' (and if
external consumers need it, re-export via "export type { ExpoPreviewMode } from
'@/agents/types';"); leave UserTier and RuntimeType as local types only if they
are not defined elsewhere. Ensure you reference the symbols ExpoPreviewMode,
UserTier, and RuntimeType in the file to verify the correct import/re-export is
used.
| 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") |
There was a problem hiding this comment.
Contradictory path instructions will confuse the LLM.
Lines 13-15 correctly state to NEVER use absolute paths like /home/user/..., but Line 16 instructs to use the "actual path" with an example of /home/user/components/Button.tsx. This contradiction could cause the LLM to generate incorrect paths.
Per coding guidelines, relative paths should be used ONLY in generated code and file tool calls.
Proposed fix to resolve the contradiction
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")
+- The readFiles tool also accepts relative paths (e.g., "components/Button.tsx")🤖 Prompt for AI Agents
In `@src/prompts/expo.ts` around lines 12 - 16, Update the "File Safety Rules"
block in src/prompts/expo.ts to remove the contradiction: change the line that
currently tells the LLM to "use the actual path (e.g.
'/home/user/components/Button.tsx')" so it instead instructs that absolute
system paths (like '/home/user/...') must NOT be used in generated code or file
tool calls and that absolute paths are only allowed when explicitly required for
readFiles or runtime file-system access; reference the existing rule titles
"File Safety Rules" and the term "readFiles" so the text consistently mandates
relative paths for generated code and clarifies when real absolute file-system
paths are permitted.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1ddbca3cc1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| capabilities.isSupported = | ||
| capabilities.sharedArrayBuffer && capabilities.crossOriginIsolated; |
There was a problem hiding this comment.
Gate WebContainer support on browser compatibility
The support check only considers SharedArrayBuffer and crossOriginIsolated, so Safari/Firefox with COOP/COEP enabled will be marked as supported even though @webcontainer/api only works in Chromium. ExpoPreviewSelector uses capabilities.isSupported to decide whether to use WebContainers, so users on those browsers will be routed to the WebContainer runtime and hit a broken preview instead of the cloud fallback. Consider folding an actual WebContainer API probe or explicit browser gating into checkWebContainerSupport so Safari/Firefox fall back reliably.
Useful? React with 👍 / 👎.
| return NextResponse.json({ | ||
| success: true, | ||
| message: `EAS ${platform} build queued for ${profile} profile`, | ||
| build: { | ||
| ...buildRequest, |
There was a problem hiding this comment.
Start an actual EAS build before returning queued response
The POST handler builds a buildRequest and immediately returns a “queued” response, but there is no call to triggerEASBuild, no Convex mutation, and no job enqueue. Any client that hits this endpoint will receive a success response while no build is actually started, and subsequent status polling can never succeed. This makes the EAS build flow effectively a no-op.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
16 issues found across 31 files
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="src/agents/webcontainer-utils.ts">
<violation number="1" location="src/agents/webcontainer-utils.ts:112">
P2: commands.run accepts timeoutMs but never enforces it, so callers’ timeouts are ignored and long-running installs/builds can hang indefinitely. Implement timeout handling (or remove the option).</violation>
<violation number="2" location="src/agents/webcontainer-utils.ts:210">
P2: Serial file writes in mountFiles violate the project’s batch-write requirement and add O(N) latency. Use writeFilesBatch (or an equivalent batch API) for non-webcontainer sandboxes.</violation>
<violation number="3" location="src/agents/webcontainer-utils.ts:303">
P2: Exit code 127 indicates the command binary is missing, not a missing npm script; this logic can silently skip real failures. Detect missing scripts via the npm error output instead.</violation>
</file>
<file name="sandbox-templates/expo-web/e2b.toml">
<violation number="1" location="sandbox-templates/expo-web/e2b.toml:10">
P2: The E2B sandbox start command launches the Expo dev server, but repository rules forbid starting dev servers in sandboxes. Replace this with a build/preview flow appropriate for sandboxes (e.g., prebuild and serve static output).</violation>
</file>
<file name="src/agents/runtime-selector.ts">
<violation number="1" location="src/agents/runtime-selector.ts:83">
P2: shouldUseWebContainersForPreview ignores the browser support flag, so it will return WebContainers even on browsers that lack SharedArrayBuffer. Thread the browserSupportsWebContainers flag through this helper to keep the fallback behavior consistent with selectRuntime.</violation>
<violation number="2" location="src/agents/runtime-selector.ts:91">
P2: getOptimalRuntimeForTask always assumes WebContainer support because it never forwards browserSupportsWebContainers to selectRuntime. Add an optional flag and pass it through so unsupported browsers fall back to E2B as intended.</violation>
</file>
<file name="sandbox-templates/expo-full/e2b.Dockerfile">
<violation number="1" location="sandbox-templates/expo-full/e2b.Dockerfile:9">
P2: Replace npx with bunx to comply with the bun-only policy.</violation>
<violation number="2" location="sandbox-templates/expo-full/e2b.Dockerfile:12">
P2: Use bun instead of npm per repo conventions to avoid violating the package manager policy.</violation>
<violation number="3" location="sandbox-templates/expo-full/e2b.Dockerfile:15">
P2: Use bunx for Expo CLI commands to follow the bun-only convention.</violation>
<violation number="4" location="sandbox-templates/expo-full/e2b.Dockerfile:18">
P2: Use bun for global installs to comply with the bun-only policy.</violation>
<violation number="5" location="sandbox-templates/expo-full/e2b.Dockerfile:23">
P1: Do not start a dev server in the sandbox entrypoint; keep the container idle or use a build/compile step instead.</violation>
</file>
<file name="src/agents/expo-qr.ts">
<violation number="1" location="src/agents/expo-qr.ts:56">
P2: Encode `projectId` and `channel` when building the QR URL so special characters can’t break the query string.</violation>
</file>
<file name="src/components/ExpoPreviewSelector.tsx">
<violation number="1" location="src/components/ExpoPreviewSelector.tsx:78">
P2: `selectedMode` is only used to initialize local state, so later prop updates will be ignored and the selector can show a stale selection. Add an effect to sync when `selectedMode` changes or treat it as a controlled prop.</violation>
</file>
<file name="sandbox-templates/expo-android/start_android.sh">
<violation number="1" location="sandbox-templates/expo-android/start_android.sh:17">
P2: Starting x11vnc with `-nopw` exposes the VNC server without authentication. Require a password to avoid unauthorized access.</violation>
</file>
<file name="sandbox-templates/expo-full/e2b.toml">
<violation number="1" location="sandbox-templates/expo-full/e2b.toml:10">
P2: This start command launches the Expo dev server in the sandbox, which is explicitly disallowed by project guidelines. Use a production build/preview command instead of `expo start` for sandbox startup.</violation>
</file>
<file name="src/agents/eas-build.ts">
<violation number="1" location="src/agents/eas-build.ts:111">
P1: Avoid interpolating EXPO_TOKEN into the command string because runCodeCommand logs the full command, which will leak the token in logs. Pass the token via environment/options and keep logs masked.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎
Codebase SummaryZapDev is an AI-powered development platform enabling web application development with live previews, sandboxed environments (E2B and WebContainers), and now full Expo/React Native support. Users can select different preview modes (web, Expo Go QR, Android emulator, and EAS build) via a UI selector, while the runtime selector automatically chooses between in-browser WebContainers and cloud E2B sandboxes. PR ChangesThis PR introduces a hybrid runtime that supports both in-browser WebContainers and cloud-based E2B sandboxes. It adds Expo/React Native integration with four preview modes (Web Preview, Expo Go QR, Android emulator, and EAS build) and enhances the agents and runtime selection logic. The UI now includes an ExpoPreviewSelector component with visual indicators for free and pro tiers, enabling users to select the preferred preview mode. Additional support for QR code generation, build-status checks, and runtime error handling has been included. Setup Instructions
Generated Test Cases1: Expo Preview Selector UI - Option Selection and Locked Badge ❗️❗️❗️Description: Tests the UI component for selecting Expo preview modes ensuring that free tier users see pro options as locked, and selection feedback is provided. Prerequisites:
Steps:
Expected Result: The preview selector displays all options in a grid. For a free tier user, the 'Pro' options show a locked badge. Selecting an unlocked option highlights it and calls the onSelect callback with the correct preview mode and runtime. 2: Runtime Selector for Expo - Web vs Native Selection ❗️❗️❗️Description: Verifies that when an Expo project is in use, the runtime selector picks the correct runtime (WebContainers for web preview and E2B for native modes) based on the selected Expo preview mode. Prerequisites:
Steps:
Expected Result: For preview mode 'expo-go', the runtime selector returns a configuration with runtimeType set to 'e2b' (native build). For preview mode 'web', the configuration indicates use of WebContainers (runtimeType 'webcontainer') if the browser supports it, otherwise falling back to 'e2b'. 3: Expo App Preview - Web Preview Mode Live Preview ❗️❗️❗️Description: Checks that when a user selects the Web Preview mode for an Expo project, the development server starts in web mode and the live preview is displayed appropriately in the browser. Prerequisites:
Steps:
Expected Result: The browser shows an instant live preview of the Expo app. Interface elements (buttons, text, images) render correctly and the developer console indicates that the dev server is running on the designated port (e.g., 8081). 4: Expo App Preview - Expo Go QR Code Generation ❗️❗️❗️Description: Ensures that when the Expo Go preview mode is selected, the application generates a QR code for device testing and the QR code is clearly visible. Prerequisites:
Steps:
Expected Result: A QR code appears on the screen, generated via the expo-qr functionality. The QR code correctly encodes a URL starting with exp:// that the Expo Go app can use. 5: EAS Build Error Handling When EXPO_ACCESS_TOKEN Is Missing ❗️❗️❗️Description: Tests that the system correctly displays an error message when attempting to trigger an EAS build without having the EXPO_ACCESS_TOKEN environment variable set. Prerequisites:
Steps:
Expected Result: The application displays a clear error message indicating that the EXPO_ACCESS_TOKEN environment variable is required for EAS builds, along with a help URL if available. 6: Visual Layout and Responsiveness of Expo Preview Selector ❗️❗️Description: Verifies that the ExpoPreviewSelector component is visually appealing, elements are correctly aligned in desktop and mobile views, and all icons, badges, and descriptions are rendered as expected. Prerequisites:
Steps:
Expected Result: The preview selector displays a neat grid with appropriate spacing. In mobile view, the layout stacks gracefully. All textual and icon elements are visible, and locked or fallback states are clearly indicated. 7: Fallback UI for Browsers Without WebContainers Support ❗️❗️Description: Ensures that when the browser does not support WebContainers (e.g., missing SharedArrayBuffer or not cross-origin isolated), the UI correctly indicates that the preview will use the cloud-based E2B sandbox. Prerequisites:
Steps:
Expected Result: The UI clearly displays that WebContainers are unavailable (e.g., by showing a 'Cloud' badge) and fallback to E2B. The runtime selector returns a configuration with runtimeType set to 'e2b'. Raw Changes AnalyzedFile: bun.lock
Changes:
@@ -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",
@@ -66,7 +67,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 +76,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 +102,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 +1028,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=="],
@@ -1156,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=="],
@@ -1350,8 +1356,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 +1540,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 +2044,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 +2732,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=="],
File: convex/importData.ts
Changes:
@@ -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(),
File: convex/sandboxSessions.ts
Changes:
@@ -16,19 +16,22 @@ 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
+ 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,
File: convex/schema.ts
Changes:
@@ -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(
@@ -55,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"),
@@ -115,6 +128,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()),
})
@@ -255,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(),
@@ -265,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"]),
});
File: convex/usage.ts
Changes:
@@ -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
File: env.example
Changes:
@@ -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=""
File: explanations/EXPO_INTEGRATION.md
Changes:
@@ -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 (
+ <View style={styles.container}>
+ <Text style={styles.title}>Hello Expo</Text>
+ <TouchableOpacity style={styles.button}>
+ <Text style={styles.buttonText}>Press Me</Text>
+ </TouchableOpacity>
+ <StatusBar style="auto" />
+ </View>
+ );
+}
+
+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 `<input type="file" accept="image/*" capture>`
+- **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)
File: explanations/WEBCONTAINERS_MIGRATION.md
Changes:
@@ -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
File: package.json
Changes:
@@ -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",
@@ -73,7 +74,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 +83,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 +109,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",
File: sandbox-templates/expo-android/e2b.Dockerfile
Changes:
@@ -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"]
File: sandbox-templates/expo-android/e2b.toml
Changes:
@@ -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
File: sandbox-templates/expo-android/start_android.sh
Changes:
@@ -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
File: sandbox-templates/expo-full/e2b.Dockerfile
Changes:
@@ -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"]
File: sandbox-templates/expo-full/e2b.toml
Changes:
@@ -0,0 +1,16 @@
+# 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)
+# Dev servers should not run automatically - they are started by agents when needed
+# start_cmd = ""
+
+# Template resource configuration
+[resources]
+cpu_count = 2
+memory_mb = 2048
File: sandbox-templates/expo-web/e2b.Dockerfile
Changes:
@@ -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"]
File: sandbox-templates/expo-web/e2b.toml
Changes:
@@ -0,0 +1,16 @@
+# 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)
+# Dev servers should not run automatically - they are started by agents when needed
+# start_cmd = ""
+
+# Template resource configuration
+[resources]
+cpu_count = 2
+memory_mb = 2048
File: src/agents/code-agent.ts
Changes:
@@ -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<Framework> {
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;
}
File: src/agents/eas-build.ts
Changes:
@@ -0,0 +1,258 @@
+import { Sandbox } from "@e2b/code-interpreter";
+import { getSandbox, runCodeCommand, writeFilesBatch } 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<void> {
+ console.log('[INFO] Initializing EAS configuration...');
+
+ const checkResult = await runCodeCommand(sandbox, 'test -f eas.json && echo "exists"');
+ const filesToWrite: Record<string, string> = {};
+
+ if (!checkResult.stdout.includes('exists')) {
+ const easConfig = {
+ cli: {
+ version: ">= 13.0.0"
+ },
+ build: {
+ development: {
+ developmentClient: true,
+ distribution: "internal"
+ },
+ preview: {
+ distribution: "internal",
+ android: {
+ buildType: "apk"
+ }
+ },
+ production: {
+ autoIncrement: true
+ }
+ },
+ submit: {
+ production: {}
+ }
+ };
+
+ filesToWrite['/home/user/eas.json'] = JSON.stringify(easConfig, null, 2);
+ console.log('[INFO] Prepared eas.json configuration');
+ }
+
+ try {
+ const appJsonContent = await sandbox.files.read('/home/user/app.json');
+ if (typeof appJsonContent === 'string') {
+ const appJson = JSON.parse(appJsonContent);
+
+ 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';
+
+ if (!appJson.expo.extra) appJson.expo.extra = {};
+ if (!appJson.expo.extra.eas) appJson.expo.extra.eas = {};
+
+ 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');
+ }
+}
+
+/**
+ * Trigger an EAS Build
+ */
+export async function triggerEASBuild(
+ sandboxId: string,
+ config: EASBuildConfig
+): Promise<EASBuildResult> {
+ 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}`);
+
+ const buildCommand = `npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`;
+
+ const result = await runCodeCommand(sandbox, buildCommand, {
+ EXPO_TOKEN: expoToken
+ });
+
+ 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<EASBuildStatus> {
+ 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<EASBuildStatus> {
+ 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<string | null> {
+ 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<boolean> {
+ 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;
+ }
+}
File: src/agents/expo-qr.ts
Changes:
@@ -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<string> {
+ 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=${encodeURIComponent(projectId)}&channel=${encodeURIComponent(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<boolean> {
+ 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;
+ }
+}
File: src/agents/runtime-selector.ts
Changes:
@@ -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,
+ };
+}
File: src/agents/sandbox-utils.ts
Changes:
@@ -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<string, Sandbox>();
const PROJECT_SANDBOX_MAP = new Map<string, string>();
@@ -137,14 +137,21 @@ export async function createSandbox(framework: Framework): Promise<Sandbox> {
// Command execution using shell (no Python kernel dependency)
export async function runCodeCommand(
sandbox: Sandbox,
- command: string
+ command: string,
+ env?: Record<string, string>
): 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:", {
@@ -307,35 +314,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 +427,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`;
};
File: src/agents/types.ts
Changes:
@@ -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];
}
File: src/agents/webcontainer-utils.ts
Changes:
@@ -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<void>;
+ read: (path: string) => Promise<string>;
+ list: (path?: string) => Promise<string[]>;
+ };
+ commands: {
+ run: (cmd: string, opts?: CommandOptions) => Promise<CommandResult>;
+ };
+ teardown: () => Promise<void>;
+ onServerReady?: (callback: (port: number, url: string) => void) => void;
+}
+
+export interface CommandOptions {
+ timeoutMs?: number;
+ background?: boolean;
+ cwd?: string;
+ env?: Record<string, string>;
+}
+
+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<typeof import("@webcontainer/api").WebContainer> | 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<SandboxInterface> {
+ 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<string, string>): 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<string, string>
+): Promise<void> {
+ 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<string> {
+ 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<void> {
+ 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<string | null> {
+ 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}`;
+}
File: src/app/api/expo/build/route.ts
Changes:
@@ -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 }
+ );
+ }
+}
File: src/components/ExpoPreviewSelector.tsx
Changes:
@@ -0,0 +1,188 @@
+'use client';
+
+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;
+ title: string;
+ description: string;
+ badge?: string;
+ buildTime: string;
+ tier: UserTier;
+ icon: string;
+ runtime: RuntimeType;
+}
+
+const PREVIEW_OPTIONS: PreviewOption[] = [
+ {
+ mode: 'web',
+ title: 'Web Preview',
+ description: 'Instant preview in browser via WebContainers',
+ buildTime: '~10 seconds',
+ tier: 'free',
+ icon: '🌐',
+ runtime: 'webcontainer'
+ },
+ {
+ mode: 'expo-go',
+ title: 'Expo Go (QR Code)',
+ description: 'Test on real device via Expo Go app',
+ buildTime: '~1-2 minutes',
+ tier: 'free',
+ icon: '📱',
+ runtime: 'e2b'
+ },
+ {
+ mode: 'android-emulator',
+ title: 'Android Emulator',
+ description: 'Full Android emulator with VNC access',
+ badge: 'Pro',
+ buildTime: '~3-5 minutes',
+ tier: 'pro',
+ icon: '🤖',
+ runtime: 'e2b'
+ },
+ {
+ mode: 'eas-build',
+ title: 'EAS Build (Production)',
+ description: 'Cloud builds for App Store/Play Store',
+ badge: 'Pro',
+ buildTime: '~5-15 minutes',
+ tier: 'pro',
+ icon: '🚀',
+ runtime: 'e2b'
+ }
+];
+
+interface ExpoPreviewSelectorProps {
+ onSelect: (mode: ExpoPreviewMode, runtime: RuntimeType) => void;
+ userTier?: UserTier;
+ selectedMode?: ExpoPreviewMode;
+ className?: string;
+}
+
+export function ExpoPreviewSelector({
+ onSelect,
+ userTier = 'free',
+ selectedMode,
+ className
+}: ExpoPreviewSelectorProps) {
+ const [selected, setSelected] = useState<ExpoPreviewMode>(selectedMode ?? 'web');
+ const [browserCapabilities, setBrowserCapabilities] = useState<BrowserCapabilities | null>(null);
+
+ useEffect(() => {
+ 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;
+
+ const tierOrder: Record<UserTier, number> = { 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);
+ const actualRuntime = webContainerUnavailable ? 'e2b' : option.runtime;
+ onSelect(mode, actualRuntime);
+ }
+ };
+
+ return (
+ <div className={cn('grid grid-cols-1 sm:grid-cols-2 gap-3', className)}>
+ {PREVIEW_OPTIONS.map((option) => {
+ const tierOrder: Record<UserTier, number> = { 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 (
+ <Card
+ key={option.mode}
+ className={cn(
+ 'cursor-pointer transition-all duration-200',
+ isSelected && 'ring-2 ring-primary bg-primary/5',
+ isLocked && 'opacity-60 cursor-not-allowed',
+ !isLocked && !isSelected && 'hover:bg-muted/50'
+ )}
+ onClick={() => handleSelect(option.mode)}
+ >
+ <CardContent className="p-4">
+ <div className="flex items-start justify-between mb-2">
+ <div className="flex items-center gap-2">
+ <span className="text-xl">{option.icon}</span>
+ <h4 className="font-semibold text-sm">{option.title}</h4>
+ </div>
+ <div className="flex gap-1">
+ {isWebContainer && webContainerSupported && (
+ <Badge variant="default" className="text-xs bg-green-600">
+ Instant
+ </Badge>
+ )}
+ {isWebContainer && !webContainerSupported && browserCapabilities && (
+ <Badge variant="outline" className="text-xs">
+ Cloud
+ </Badge>
+ )}
+ {option.badge && (
+ <Badge variant="secondary" className="text-xs">
+ {option.badge}
+ </Badge>
+ )}
+ {isLocked && (
+ <Badge variant="outline" className="text-xs">
+ 🔒
+ </Badge>
+ )}
+ </div>
+ </div>
+ <p className="text-xs text-muted-foreground mb-2">
+ {isWebContainer && !webContainerSupported && browserCapabilities
+ ? 'Preview via cloud sandbox (WebContainers unavailable)'
+ : option.description}
+ </p>
+ <p className="text-xs text-muted-foreground/70">
+ Build time: {option.buildTime}
+ </p>
+ </CardContent>
+ </Card>
+ );
+ })}
+ </div>
+ );
+}
+
+export function ExpoPreviewInfo({ mode }: { mode: ExpoPreviewMode }) {
+ const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
+ if (!option) return null;
+
+ return (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <span>{option.icon}</span>
+ <span>{option.title}</span>
+ <span className="text-xs">({option.buildTime})</span>
+ </div>
+ );
+}
+
+export { PREVIEW_OPTIONS };
File: src/lib/browser-capabilities.ts
Changes:
@@ -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,
+ };
+}
File: src/lib/frameworks.ts
Changes:
@@ -341,6 +341,73 @@ export const frameworks: Record<string, FrameworkData> = {
'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'
+ ]
}
};
File: src/prompt.ts
Changes:
@@ -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";
File: src/prompts/expo.ts
Changes:
@@ -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 <package> --yes" or "npx expo install <package>")
+- 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 <task_summary> 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 <package>)
+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:
+
+<task_summary>
+A short, high-level summary of what was created or changed.
+</task_summary>
+`;
+
+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 (
+ <View style={styles.container}>
+ <Text style={styles.title}>Hello Expo</Text>
+ <TouchableOpacity style={styles.button} onPress={() => console.log('Pressed')}>
+ <Text style={styles.buttonText}>Press Me</Text>
+ </TouchableOpacity>
+ <StatusBar style="auto" />
+ </View>
+ );
+}
+
+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 <Stack />;
+}
+
+// app/index.tsx
+import { Link } from 'expo-router';
+import { View, Text } from 'react-native';
+
+export default function Home() {
+ return (
+ <View>
+ <Text>Home Screen</Text>
+ <Link href="/details">Go to Details</Link>
+ </View>
+ );
+}
+\`\`\`
+
+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 <package>)
+3. FINALLY: Provide <task_summary> 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 \`<input type="file" accept="image/*" capture>\`
+- 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]}
+`;
File: src/prompts/framework-selector.ts
Changes:
@@ -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.
`;
File: src/proxy.ts
Changes:
@@ -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)(.*)",
],
};
|
|
🚀 Launching Scrapybara desktop... |
|
❌ Something went wrong: |
Deployment failedThis pull request failed while building automatically on Stormkit. You can preview the logs using the following link. |
There was a problem hiding this comment.
1 issue found across 6 files (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="src/agents/sandbox-utils.ts">
<violation number="1" location="src/agents/sandbox-utils.ts:150">
P2: Shell command builds env assignments without escaping, so env values containing quotes/$/backticks can break the command or inject shell code. Since expoToken can come from user input, escape env values before concatenation.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| ? Object.entries(env).map(([key, value]) => `${key}="${value}"`).join(' ') + ' ' | ||
| : ''; |
There was a problem hiding this comment.
P2: Shell command builds env assignments without escaping, so env values containing quotes/$/backticks can break the command or inject shell code. Since expoToken can come from user input, escape env values before concatenation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/sandbox-utils.ts, line 150:
<comment>Shell command builds env assignments without escaping, so env values containing quotes/$/backticks can break the command or inject shell code. Since expoToken can come from user input, escape env values before concatenation.</comment>
<file context>
@@ -137,14 +137,21 @@ export async function createSandbox(framework: Framework): Promise<Sandbox> {
- 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(' ') + ' '
+ : '';
+
</file context>
| ? Object.entries(env).map(([key, value]) => `${key}="${value}"`).join(' ') + ' ' | |
| : ''; | |
| ? Object.entries(env) | |
| .map(([key, value]) => { | |
| const safeValue = String(value).replace(/["\\$`]/g, '\\$&'); | |
| return `${key}="${safeValue}"`; | |
| }) | |
| .join(' ') + ' ' | |
| : ''; |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/components/ExpoPreviewSelector.tsx`:
- Around line 98-106: The code currently treats webContainerUnavailable only
when browserCapabilities exists and isSupported is false, so when
browserCapabilities is null the handler still selects 'webcontainer' — change
the webContainerUnavailable check to treat a null/undefined browserCapabilities
as unsupported (e.g., webContainerUnavailable = option.runtime ===
'webcontainer' && (!browserCapabilities || !browserCapabilities.isSupported')),
then use that variable when computing actualRuntime and calling
setSelected/onSelect (the existing references to webContainerUnavailable,
option.runtime, setSelected, onSelect and isLocked should be updated
accordingly) so the component selects 'e2b' instead of 'webcontainer' when
browser capabilities are unknown or unsupported.
- Around line 120-129: The Card elements rendered in ExpoPreviewSelector are
clickable but not keyboard-accessible; update the Card at the render where
key={option.mode} to include role="button" and a tabIndex of 0 when not isLocked
(tabIndex should be -1 or omitted if isLocked), add an onKeyDown handler that
listens for Enter and Space and calls handleSelect(option.mode) (respecting
isLocked), and add focus-visible styles to the className (e.g., the same ring-2
ring-primary styling used for selection) so keyboard focus shows the visual cue;
ensure the existing onClick(handlerSelect) and isLocked checks are preserved.
♻️ Duplicate comments (2)
src/components/ExpoPreviewSelector.tsx (1)
3-11: Use the canonicalExpoPreviewModetype to avoid drift.This type is already defined centrally; importing it prevents divergence.
♻️ Suggested refactor
-import { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; +import type { ExpoPreviewMode } from '@/agents/types'; @@ -export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build';src/agents/eas-build.ts (1)
94-118: Add retry/backoff for EAS build failures.Build commands should retry up to 2 times with error context before failing.
Expo EAS CLI recommended retry/backoff or rate limit guidance for build commandsBased on learnings, add a small retry loop with brief backoff around the build command.
| <Card | ||
| key={option.mode} | ||
| className={cn( | ||
| 'cursor-pointer transition-all duration-200', | ||
| isSelected && 'ring-2 ring-primary bg-primary/5', | ||
| isLocked && 'opacity-60 cursor-not-allowed', | ||
| !isLocked && !isSelected && 'hover:bg-muted/50' | ||
| )} | ||
| onClick={() => handleSelect(option.mode)} | ||
| > |
There was a problem hiding this comment.
Make preview cards keyboard-accessible.
The Card is clickable but not focusable or keyboard operable. Add role/tabIndex, key handling, and focus-visible styles.
♿️ Suggested fix
<Card
key={option.mode}
className={cn(
- 'cursor-pointer transition-all duration-200',
+ 'cursor-pointer transition-all duration-200 focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring',
isSelected && 'ring-2 ring-primary bg-primary/5',
isLocked && 'opacity-60 cursor-not-allowed',
!isLocked && !isSelected && 'hover:bg-muted/50'
)}
- onClick={() => handleSelect(option.mode)}
+ role="button"
+ tabIndex={isLocked ? -1 : 0}
+ aria-disabled={isLocked}
+ onKeyDown={(e) => {
+ if (isLocked) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleSelect(option.mode);
+ }
+ }}
+ onClick={() => handleSelect(option.mode)}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <Card | |
| key={option.mode} | |
| className={cn( | |
| 'cursor-pointer transition-all duration-200', | |
| isSelected && 'ring-2 ring-primary bg-primary/5', | |
| isLocked && 'opacity-60 cursor-not-allowed', | |
| !isLocked && !isSelected && 'hover:bg-muted/50' | |
| )} | |
| onClick={() => handleSelect(option.mode)} | |
| > | |
| <Card | |
| key={option.mode} | |
| className={cn( | |
| 'cursor-pointer transition-all duration-200 focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring', | |
| isSelected && 'ring-2 ring-primary bg-primary/5', | |
| isLocked && 'opacity-60 cursor-not-allowed', | |
| !isLocked && !isSelected && 'hover:bg-muted/50' | |
| )} | |
| role="button" | |
| tabIndex={isLocked ? -1 : 0} | |
| aria-disabled={isLocked} | |
| onKeyDown={(e) => { | |
| if (isLocked) return; | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| handleSelect(option.mode); | |
| } | |
| }} | |
| onClick={() => handleSelect(option.mode)} | |
| > |
🤖 Prompt for AI Agents
In `@src/components/ExpoPreviewSelector.tsx` around lines 120 - 129, The Card
elements rendered in ExpoPreviewSelector are clickable but not
keyboard-accessible; update the Card at the render where key={option.mode} to
include role="button" and a tabIndex of 0 when not isLocked (tabIndex should be
-1 or omitted if isLocked), add an onKeyDown handler that listens for Enter and
Space and calls handleSelect(option.mode) (respecting isLocked), and add
focus-visible styles to the className (e.g., the same ring-2 ring-primary
styling used for selection) so keyboard focus shows the visual cue; ensure the
existing onClick(handlerSelect) and isLocked checks are preserved.
CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎
Codebase SummaryZapDev is an AI-powered development platform that allows users to create web applications through real-time conversations with AI agents in sandboxed environments. The latest changes introduce a hybrid runtime selection mechanism that chooses between in-browser WebContainers and E2B cloud sandboxes, enabling full Expo/React Native support with multiple preview modes (web, Expo Go via QR, Android emulator, and EAS build). Updates include new UI selectors, QR utilities, Convex schema modifications, and extensive documentation for Expo integration and WebContainers migration. PR ChangesThis PR adds support for WebContainers and Expo/React Native integration. It includes a runtime selector that automatically chooses between browser-based WebContainers and E2B sandboxes based on task requirements and browser capabilities. Key features include an Expo preview selector UI (with options for Web Preview, Expo Go, Android Emulator, and EAS Build), enhanced Convex schema for Expo preview modes and runtime types, updated dependency and lock files, migration documentation for WebContainers and Expo, and integration of QR code generation for Expo Go scanning. Setup InstructionsTo set up the test environment:
Generated Test Cases1: Expo Preview Selector - Display for Free Tier ❗️❗️❗️Description: This test validates that the Expo Preview Selector UI correctly displays preview options based on the user's subscription tier. Free-tier users should have 'Web Preview' and 'Expo Go' options enabled, while options that require a Pro subscription ('Android Emulator' and 'EAS Build') should show a lock indicator. Prerequisites:
Steps:
Expected Result: The user sees only the free-tier options (Web Preview and Expo Go) enabled with interactive elements, while 'Android Emulator' and 'EAS Build' are visibly locked (indicated by a badge such as 'Pro' or a lock icon) and not selectable. 2: Expo Preview Selector - Option Selection Triggers Correct Callback ❗️❗️❗️Description: This test verifies that when a user selects an option from the Expo Preview Selector, the appropriate callback is triggered with the correct mode and runtime settings. Depending on browser capabilities, the runtime should be set to 'webcontainer' (if supported) or fall back to 'e2b'. Prerequisites:
Steps:
Expected Result: Selecting 'Web Preview' should trigger the callback with mode 'web' and runtime as 'webcontainer' (if the browser supports WebContainers) or 'e2b' if not. For other options, the callback should reflect the chosen mode and the required runtime ('e2b' for native preview modes). 3: EAS Build API - Valid POST Request ❗️❗️Description: This test checks the EAS Build API integration through the UI. When a user submits a valid build request (selecting a platform, project ID, and profile), the application should queue the build and display a success message with estimated build times. Prerequisites:
Steps:
Expected Result: The UI should display a confirmation message that the EAS build was successfully queued, showing details like the platform, profile, and an estimated build time. 4: WebContainers Migration - Verify COOP and COEP Headers ❗️❗️Description: This test ensures that the application's proxy middleware correctly sets the Cross-Origin-Embedder-Policy (COEP) and Cross-Origin-Opener-Policy (COOP) headers, which are required for WebContainers to function. Prerequisites:
Steps:
Expected Result: The response headers include 'Cross-Origin-Embedder-Policy: credentialless' and 'Cross-Origin-Opener-Policy: same-origin', confirming proper configuration for WebContainers. 5: Expo Integration Guide Page Loads Correctly ❗️Description: This test checks that the updated Expo Integration Guide page renders correctly with all new documentation, code examples, and integration steps visible to the user. Prerequisites:
Steps:
Expected Result: The Expo Integration Guide page loads fully with clearly organized sections, proper headings, code blocks, and descriptions that match the updated documentation provided in the PR. Raw Changes AnalyzedFile: bun.lock
Changes:
@@ -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",
@@ -66,7 +67,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 +76,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 +102,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 +1028,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=="],
@@ -1156,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=="],
@@ -1350,8 +1356,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 +1540,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 +2044,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 +2732,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=="],
File: convex/importData.ts
Changes:
@@ -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(),
File: convex/sandboxSessions.ts
Changes:
@@ -16,19 +16,22 @@ 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
+ 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,
File: convex/schema.ts
Changes:
@@ -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(
@@ -55,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"),
@@ -115,6 +128,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()),
})
@@ -255,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(),
@@ -265,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"]),
});
File: convex/usage.ts
Changes:
@@ -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
File: env.example
Changes:
@@ -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=""
File: explanations/EXPO_INTEGRATION.md
Changes:
@@ -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 (
+ <View style={styles.container}>
+ <Text style={styles.title}>Hello Expo</Text>
+ <TouchableOpacity style={styles.button}>
+ <Text style={styles.buttonText}>Press Me</Text>
+ </TouchableOpacity>
+ <StatusBar style="auto" />
+ </View>
+ );
+}
+
+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 `<input type="file" accept="image/*" capture>`
+- **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)
File: explanations/WEBCONTAINERS_MIGRATION.md
Changes:
@@ -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
File: package.json
Changes:
@@ -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",
@@ -73,7 +74,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 +83,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 +109,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",
File: sandbox-templates/expo-android/e2b.Dockerfile
Changes:
@@ -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"]
File: sandbox-templates/expo-android/e2b.toml
Changes:
@@ -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
File: sandbox-templates/expo-android/start_android.sh
Changes:
@@ -0,0 +1,58 @@
+#!/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 &
+
+# 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..."
+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
+
+# 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
File: sandbox-templates/expo-full/e2b.Dockerfile
Changes:
@@ -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 bunx create-expo-app@latest . --template blank-typescript --yes
+
+# Install web dependencies
+RUN bun add react-dom react-native-web @expo/metro-runtime
+
+# Install common Expo SDK modules
+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 bun add -g @expo/cli eas-cli
+
+WORKDIR /home/user
+
+# Keep container idle - dev servers are started by agents when needed
+CMD ["bash"]
File: sandbox-templates/expo-full/e2b.toml
Changes:
@@ -0,0 +1,16 @@
+# 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)
+# Dev servers should not run automatically - they are started by agents when needed
+# start_cmd = ""
+
+# Template resource configuration
+[resources]
+cpu_count = 2
+memory_mb = 2048
File: sandbox-templates/expo-web/e2b.Dockerfile
Changes:
@@ -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"]
File: sandbox-templates/expo-web/e2b.toml
Changes:
@@ -0,0 +1,16 @@
+# 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)
+# Dev servers should not run automatically - they are started by agents when needed
+# start_cmd = ""
+
+# Template resource configuration
+[resources]
+cpu_count = 2
+memory_mb = 2048
File: src/agents/code-agent.ts
Changes:
@@ -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<Framework> {
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;
}
File: src/agents/eas-build.ts
Changes:
@@ -0,0 +1,262 @@
+import { Sandbox } from "@e2b/code-interpreter";
+import { getSandbox, runCodeCommand, writeFilesBatch } 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<void> {
+ console.log('[INFO] Initializing EAS configuration...');
+
+ const checkResult = await runCodeCommand(sandbox, 'test -f eas.json && echo "exists"');
+ const filesToWrite: Record<string, string> = {};
+
+ if (!checkResult.stdout.includes('exists')) {
+ const easConfig = {
+ cli: {
+ version: ">= 13.0.0"
+ },
+ build: {
+ development: {
+ developmentClient: true,
+ distribution: "internal"
+ },
+ preview: {
+ distribution: "internal",
+ android: {
+ buildType: "apk"
+ }
+ },
+ production: {
+ autoIncrement: true
+ }
+ },
+ submit: {
+ production: {}
+ }
+ };
+
+ filesToWrite['/home/user/eas.json'] = JSON.stringify(easConfig, null, 2);
+ console.log('[INFO] Prepared eas.json configuration');
+ }
+
+ try {
+ const appJsonContent = await sandbox.files.read('/home/user/app.json');
+ if (typeof appJsonContent === 'string') {
+ const appJson = JSON.parse(appJsonContent);
+
+ 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';
+
+ if (!appJson.expo.extra) appJson.expo.extra = {};
+ if (!appJson.expo.extra.eas) appJson.expo.extra.eas = {};
+
+ 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');
+ }
+}
+
+/**
+ * Trigger an EAS Build
+ */
+export async function triggerEASBuild(
+ sandboxId: string,
+ config: EASBuildConfig
+): Promise<EASBuildResult> {
+ 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}`);
+
+ 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
+ });
+
+ 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<EASBuildStatus> {
+ 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<EASBuildStatus> {
+ 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<string | null> {
+ 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<boolean> {
+ 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;
+ }
+}
File: src/agents/expo-qr.ts
Changes:
@@ -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<string> {
+ 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=${encodeURIComponent(projectId)}&channel=${encodeURIComponent(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<boolean> {
+ 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;
+ }
+}
File: src/agents/runtime-selector.ts
Changes:
@@ -0,0 +1,174 @@
+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,
+ browserSupportsWebContainers: boolean = true
+): boolean {
+ const config = selectRuntime(framework, "preview", expoPreviewMode, browserSupportsWebContainers);
+ return config.useWebContainers;
+}
+
+export function getOptimalRuntimeForTask(
+ framework: Framework,
+ userPrompt: string,
+ expoPreviewMode?: ExpoPreviewMode,
+ browserSupportsWebContainers: boolean = true
+): 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, browserSupportsWebContainers);
+ }
+
+ 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, browserSupportsWebContainers);
+ }
+
+ return selectRuntime(framework, "full-dev", expoPreviewMode, browserSupportsWebContainers);
+}
+
+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,
+ };
+}
File: src/agents/sandbox-utils.ts
Changes:
@@ -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<string, Sandbox>();
const PROJECT_SANDBOX_MAP = new Map<string, string>();
@@ -137,14 +137,21 @@ export async function createSandbox(framework: Framework): Promise<Sandbox> {
// Command execution using shell (no Python kernel dependency)
export async function runCodeCommand(
sandbox: Sandbox,
- command: string
+ command: string,
+ env?: Record<string, string>
): 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:", {
@@ -307,35 +314,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 +427,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`;
};
File: src/agents/types.ts
Changes:
@@ -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];
}
File: src/agents/webcontainer-utils.ts
Changes:
@@ -0,0 +1,334 @@
+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<void>;
+ read: (path: string) => Promise<string>;
+ list: (path?: string) => Promise<string[]>;
+ };
+ commands: {
+ run: (cmd: string, opts?: CommandOptions) => Promise<CommandResult>;
+ };
+ teardown: () => Promise<void>;
+ onServerReady?: (callback: (port: number, url: string) => void) => void;
+}
+
+export interface CommandOptions {
+ timeoutMs?: number;
+ background?: boolean;
+ cwd?: string;
+ env?: Record<string, string>;
+}
+
+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<typeof import("@webcontainer/api").WebContainer> | 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<SandboxInterface> {
+ 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 };
+ }
+
+ // Timeout handling: race between process exit and timeout
+ const timeoutMs = opts?.timeoutMs ?? 300000; // Default 5 minutes
+ const timeoutPromise = new Promise<never>((_, 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 = "";
+
+ 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<string, string>): 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<string, string>
+): Promise<void> {
+ if (sandbox.runtimeType !== "webcontainer") {
+ // 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;
+ }
+
+ 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<string> {
+ 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<void> {
+ 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<string | null> {
+ console.log("[WebContainer] Running build check...");
+ const result = await sandbox.commands.run("npm run build", { timeoutMs: 120000 });
+
+ // 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;
+ }
+
+ 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}`;
+}
File: src/app/api/expo/build/route.ts
Changes:
@@ -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 }
+ );
+ }
+}
File: src/components/ExpoPreviewSelector.tsx
Changes:
@@ -0,0 +1,183 @@
+'use client';
+
+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;
+ title: string;
+ description: string;
+ badge?: string;
+ buildTime: string;
+ tier: UserTier;
+ icon: string;
+ runtime: RuntimeType;
+}
+
+const PREVIEW_OPTIONS: PreviewOption[] = [
+ {
+ mode: 'web',
+ title: 'Web Preview',
+ description: 'Instant preview in browser via WebContainers',
+ buildTime: '~10 seconds',
+ tier: 'free',
+ icon: '🌐',
+ runtime: 'webcontainer'
+ },
+ {
+ mode: 'expo-go',
+ title: 'Expo Go (QR Code)',
+ description: 'Test on real device via Expo Go app',
+ buildTime: '~1-2 minutes',
+ tier: 'free',
+ icon: '📱',
+ runtime: 'e2b'
+ },
+ {
+ mode: 'android-emulator',
+ title: 'Android Emulator',
+ description: 'Full Android emulator with VNC access',
+ badge: 'Pro',
+ buildTime: '~3-5 minutes',
+ tier: 'pro',
+ icon: '🤖',
+ runtime: 'e2b'
+ },
+ {
+ mode: 'eas-build',
+ title: 'EAS Build (Production)',
+ description: 'Cloud builds for App Store/Play Store',
+ badge: 'Pro',
+ buildTime: '~5-15 minutes',
+ tier: 'pro',
+ icon: '🚀',
+ runtime: 'e2b'
+ }
+];
+
+interface ExpoPreviewSelectorProps {
+ onSelect: (mode: ExpoPreviewMode, runtime: RuntimeType) => void;
+ userTier?: UserTier;
+ selectedMode?: ExpoPreviewMode;
+ className?: string;
+}
+
+export function ExpoPreviewSelector({
+ onSelect,
+ userTier = 'free',
+ selectedMode,
+ className
+}: ExpoPreviewSelectorProps) {
+ const [browserCapabilities, setBrowserCapabilities] = useState<BrowserCapabilities | null>(null);
+
+ useEffect(() => {
+ setBrowserCapabilities(checkWebContainerSupport());
+ }, []);
+
+ // Use selectedMode directly as controlled component
+ const selected = selectedMode ?? 'web';
+
+ const handleSelect = (mode: ExpoPreviewMode) => {
+ const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
+ if (!option) return;
+
+ const tierOrder: Record<UserTier, number> = { free: 0, pro: 1, enterprise: 2 };
+ const isLocked = tierOrder[userTier] < tierOrder[option.tier];
+
+ const webContainerUnavailable =
+ option.runtime === 'webcontainer' &&
+ browserCapabilities &&
+ !browserCapabilities.isSupported;
+
+ if (!isLocked) {
+ const actualRuntime = webContainerUnavailable ? 'e2b' : option.runtime;
+ onSelect(mode, actualRuntime);
+ }
+ };
+
+ return (
+ <div className={cn('grid grid-cols-1 sm:grid-cols-2 gap-3', className)}>
+ {PREVIEW_OPTIONS.map((option) => {
+ const tierOrder: Record<UserTier, number> = { 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 (
+ <Card
+ key={option.mode}
+ className={cn(
+ 'cursor-pointer transition-all duration-200',
+ isSelected && 'ring-2 ring-primary bg-primary/5',
+ isLocked && 'opacity-60 cursor-not-allowed',
+ !isLocked && !isSelected && 'hover:bg-muted/50'
+ )}
+ onClick={() => handleSelect(option.mode)}
+ >
+ <CardContent className="p-4">
+ <div className="flex items-start justify-between mb-2">
+ <div className="flex items-center gap-2">
+ <span className="text-xl">{option.icon}</span>
+ <h4 className="font-semibold text-sm">{option.title}</h4>
+ </div>
+ <div className="flex gap-1">
+ {isWebContainer && webContainerSupported && (
+ <Badge variant="default" className="text-xs bg-green-600">
+ Instant
+ </Badge>
+ )}
+ {isWebContainer && !webContainerSupported && browserCapabilities && (
+ <Badge variant="outline" className="text-xs">
+ Cloud
+ </Badge>
+ )}
+ {option.badge && (
+ <Badge variant="secondary" className="text-xs">
+ {option.badge}
+ </Badge>
+ )}
+ {isLocked && (
+ <Badge variant="outline" className="text-xs">
+ 🔒
+ </Badge>
+ )}
+ </div>
+ </div>
+ <p className="text-xs text-muted-foreground mb-2">
+ {isWebContainer && !webContainerSupported && browserCapabilities
+ ? 'Preview via cloud sandbox (WebContainers unavailable)'
+ : option.description}
+ </p>
+ <p className="text-xs text-muted-foreground/70">
+ Build time: {option.buildTime}
+ </p>
+ </CardContent>
+ </Card>
+ );
+ })}
+ </div>
+ );
+}
+
+export function ExpoPreviewInfo({ mode }: { mode: ExpoPreviewMode }) {
+ const option = PREVIEW_OPTIONS.find(o => o.mode === mode);
+ if (!option) return null;
+
+ return (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <span>{option.icon}</span>
+ <span>{option.title}</span>
+ <span className="text-xs">({option.buildTime})</span>
+ </div>
+ );
+}
+
+export { PREVIEW_OPTIONS };
File: src/lib/browser-capabilities.ts
Changes:
@@ -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,
+ };
+}
File: src/lib/frameworks.ts
Changes:
@@ -341,6 +341,73 @@ export const frameworks: Record<string, FrameworkData> = {
'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'
+ ]
}
};
File: src/prompt.ts
Changes:
@@ -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";
File: src/prompts/expo.ts
Changes:
@@ -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 <package> --yes" or "npx expo install <package>")
+- 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 <task_summary> 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 <package>)
+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:
+
+<task_summary>
+A short, high-level summary of what was created or changed.
+</task_summary>
+`;
+
+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 (
+ <View style={styles.container}>
+ <Text style={styles.title}>Hello Expo</Text>
+ <TouchableOpacity style={styles.button} onPress={() => console.log('Pressed')}>
+ <Text style={styles.buttonText}>Press Me</Text>
+ </TouchableOpacity>
+ <StatusBar style="auto" />
+ </View>
+ );
+}
+
+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 <Stack />;
+}
+
+// app/index.tsx
+import { Link } from 'expo-router';
+import { View, Text } from 'react-native';
+
+export default function Home() {
+ return (
+ <View>
+ <Text>Home Screen</Text>
+ <Link href="/details">Go to Details</Link>
+ </View>
+ );
+}
+\`\`\`
+
+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 <package>)
+3. FINALLY: Provide <task_summary> 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 \`<input type="file" accept="image/*" capture>\`
+- 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]}
+`;
File: src/prompts/framework-selector.ts
Changes:
@@ -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.
`;
File: src/proxy.ts
Changes:
@@ -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)(.*)",
],
};
|
|
🚀 Launching Scrapybara desktop... |
|
❌ Something went wrong: |
Deployment failedThis pull request failed while building automatically on Stormkit. You can preview the logs using the following link. |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@sandbox-templates/expo-android/start_android.sh`:
- Around line 15-19: The VNC password file is being written as plaintext (using
echo) which x11vnc will reject; inside start_android.sh where VNC_PASSWD_FILE is
set and the "if [ ! -f \"$VNC_PASSWD_FILE\" ]" block exists, replace the
plaintext write with a call to x11vnc -storepasswd to create a proper encrypted
password file using the VNC_PASSWORD env var (fallback to "vncpasswd" if unset),
and ensure the created file permissions are restricted (owner-read/write only).
Use the x11vnc -storepasswd mechanism rather than echo so x11vnc -rfbauth will
accept the file.
In `@sandbox-templates/expo-full/e2b.Dockerfile`:
- Around line 2-18: The Dockerfile calls bunx/bun (e.g., RUN bunx
create-expo-app@latest . and RUN bun add ...) but never installs Bun; add a step
before any bunx/bun commands to install Bun (for example via the official
install script) and ensure the Bun bin directory is added to PATH for subsequent
RUN lines so commands like bunx create-expo-app and bun add -g `@expo/cli`
succeed; update the Dockerfile around the top of the image (before WORKDIR or
before the first RUN using bunx) to perform the install and PATH export so the
existing RUN bunx and RUN bun add lines do not fail.
In `@src/agents/eas-build.ts`:
- Around line 61-78: The code uses hardcoded absolute paths
'/home/user/eas.json' and '/home/user/app.json' when populating filesToWrite and
calling sandbox.files.read; change this to compute a sandbox root (e.g., from an
env var or a repo-relative constant) and reuse it consistently: replace literal
'/home/user' occurrences with a single variable (e.g., sandboxRoot) and build
paths like `${sandboxRoot}/eas.json` and `${sandboxRoot}/app.json`, update
references in filesToWrite and sandbox.files.read accordingly, and ensure
sandboxRoot is derived from process.env or a relative path so tests and
different environments work.
♻️ Duplicate comments (9)
sandbox-templates/expo-android/start_android.sh (2)
35-35: Quote$ANDROID_HOMEto prevent globbing and word splitting.-$ANDROID_HOME/emulator/emulator -avd expo_emulator \ +"$ANDROID_HOME/emulator/emulator" -avd expo_emulator \
56-58: Handlecdfailure and prefer environment-based paths.The
cdwithout error handling was already flagged. Additionally, based on learnings, prefer environment-based paths over hardcoded absolute paths like/home/user.Proposed fix
# Start Expo Metro bundler with Android -cd /home/user +cd "${E2B_WORKDIR:-/home/user}" || { echo "[ERROR] Failed to change to working directory"; exit 1; } echo "[INFO] Starting Expo development server..." npx expo start --android --port 8081 --host 0.0.0.0This allows the working directory to be configured via environment variable while providing a safe fallback. Based on learnings, use environment-based paths rather than hardcoded absolute paths.
src/agents/runtime-selector.ts (2)
83-90: Previous issue resolved.The function now correctly accepts and forwards
browserSupportsWebContainerstoselectRuntime, ensuring proper fallback behavior for unsupported browsers.
92-137: Previous issue resolved.The function now correctly accepts
browserSupportsWebContainersparameter (line 96) and forwards it to allselectRuntimecalls (lines 116, 133, 136).src/agents/webcontainer-utils.ts (4)
216-223: Previous issue resolved.Parallel writes with
Promise.alladdress the O(N) latency concern from serial file writes.
245-249: Duplicate branches return identical command.Both the
ifbranch (line 247) and fallback (line 249) return the same command. The condition is redundant.♻️ Proposed fix
case "expo": - if (expoPreviewMode === "web" || !expoPreviewMode) { - return "npx expo start --web --port 8081"; - } + // Expo web preview - other modes (expo-go, android-emulator) use E2B return "npx expo start --web --port 8081";
309-329: Previous issue resolved.The build check now correctly detects missing scripts by examining the npm error output for "Missing script" rather than relying solely on exit code 127.
135-149:stderris never populated from WebContainer output.WebContainer's
process.outputis a combined stdout/stderr stream. The current implementation reads intostdoutbut leavesstderras an empty string. Callers checkingstderrfor error details will miss them.♻️ Proposed fix: Mirror stdout to stderr or document behavior
let stdout = ""; - let stderr = ""; + let stderr = ""; // WebContainer combines stdout/stderr in process.output 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 }; + // Mirror combined output to stderr for callers that check it + return { stdout, stderr: stdout, exitCode };src/agents/eas-build.ts (1)
110-123: Add retry logic for transient EAS build failures.The build command still fails immediately on first error. Agent guidance calls for up to 2 retries with error context. Based on learnings, ...
♻️ Suggested retry pattern
- const result = await runCodeCommand(sandbox, buildCommand, { - EXPO_TOKEN: expoToken - }); - - if (result.exitCode !== 0) { - console.error('[ERROR] EAS build command failed:', result.stderr); - throw new Error(`EAS build failed: ${result.stderr || result.stdout}`); - } + let lastError: Error | null = null; + for (let attempt = 0; attempt <= 2; attempt++) { + if (attempt > 0) { + console.log(`[INFO] Retrying EAS build (attempt ${attempt + 1}/3)...`); + } + + const result = await runCodeCommand(sandbox, buildCommand, { + EXPO_TOKEN: expoToken + }); + + if (result.exitCode === 0) { + // parse and return (existing logic) + ... + } + + lastError = new Error(`EAS build failed: ${result.stderr || result.stdout}`); + console.error(`[ERROR] EAS build attempt ${attempt + 1} failed:`, result.stderr); + } + + throw lastError!;
🧹 Nitpick comments (1)
src/agents/webcontainer-utils.ts (1)
3-3: Consider consolidatingRuntimeTypedefinition.
RuntimeTypeis defined here and also insrc/components/ExpoPreviewSelector.tsx. To avoid duplication and potential drift, export from a single canonical location (e.g.,src/agents/types.ts) and import elsewhere.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 6 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
This PR is being reviewed by Cursor Bugbot
Details
You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.
To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.
There was a problem hiding this comment.
5 issues found across 6 files (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="sandbox-templates/expo-full/e2b.Dockerfile">
<violation number="1" location="sandbox-templates/expo-full/e2b.Dockerfile:9">
P1: Bun is used without being installed in the base image, so the Docker build will fail when `bunx`/`bun` commands run. Add a Bun installation step before the first `bunx`/`bun` invocation.</violation>
</file>
<file name="sandbox-templates/expo-android/start_android.sh">
<violation number="1" location="sandbox-templates/expo-android/start_android.sh:18">
P2: The VNC password file is written as plaintext. `x11vnc -rfbauth` expects a password file generated by `vncpasswd`/`x11vnc -storepasswd`, so this will either fail authentication or leave credentials in cleartext. Use `vncpasswd -f` (or `x11vnc -storepasswd`) to generate the file and set proper permissions.</violation>
<violation number="2" location="sandbox-templates/expo-android/start_android.sh:27">
P1: The fallback starts x11vnc without authentication when the password file is missing, which exposes an unauthenticated VNC server if password creation fails. Prefer failing fast (or retrying) instead of opening VNC without auth.</violation>
</file>
<file name="src/components/ExpoPreviewSelector.tsx">
<violation number="1" location="src/components/ExpoPreviewSelector.tsx:85">
P2: Selection is now fully controlled by `selectedMode`, but the prop is optional. If a parent doesn’t pass `selectedMode`, the UI will stay on `'web'` after clicks. Either keep internal state for uncontrolled usage or make `selectedMode` required and ensure the parent updates it.</violation>
</file>
<file name="src/agents/webcontainer-utils.ts">
<violation number="1" location="src/agents/webcontainer-utils.ts:127">
P3: Clear the timeout timer after the process exits; otherwise each command leaves a pending `setTimeout`, which accumulates timers and keeps the runtime busy for up to the full timeout duration.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Summary by cubic
Added WebContainers and full Expo/React Native support with a hybrid runtime that selects between in-browser WebContainers and E2B cloud sandboxes. This enables fast web previews, native device testing, Android emulator, and EAS production builds.
New Features
Migration
Written for commit e2be46e. Summary will update on new commits.
Summary by CodeRabbit
New Features
Documentation
Chores
✏️ Tip: You can customize this high-level summary in your review settings.
Note
Enables fast in-browser previews via WebContainers and native workflows via E2B, adding end-to-end Expo/React Native support.
src/agents/runtime-selector.ts) and WebContainer utilities (src/agents/webcontainer-utils.ts); sets COOP/COEP headers insrc/proxy.tssrc/prompts/expo.ts,src/components/ExpoPreviewSelector.tsx) plus QR utilities and build tooling (src/agents/expo-qr.ts,src/agents/eas-build.ts,src/app/api/expo/build/route.ts)EXPO, Expo preview fields, andruntimeTypein sandbox sessions (convex/schema.ts,convex/sandboxSessions.ts,convex/importData.ts)sandbox-templates/expo-*)explanations/EXPO_INTEGRATION.md,explanations/WEBCONTAINERS_MIGRATION.md); updates env withEXPO_ACCESS_TOKEN@webcontainer/api,qrcode(and types); removesexa-jsWritten by Cursor Bugbot for commit e2be46e. Configure here.