diff --git a/packages/cta-ui-base/package.json b/packages/cta-ui-base/package.json
index 116b35fa..27b2a43a 100644
--- a/packages/cta-ui-base/package.json
+++ b/packages/cta-ui-base/package.json
@@ -32,6 +32,7 @@
"@tanstack/react-query": "^5.66.5",
"@uiw/codemirror-theme-github": "^4.23.10",
"@uiw/react-codemirror": "^4.23.10",
+ "@webcontainer/api": "^1.3.5",
"chalk": "^5.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/packages/cta-ui-base/src/components/file-navigator.tsx b/packages/cta-ui-base/src/components/file-navigator.tsx
index 7dbd64c8..3e694235 100644
--- a/packages/cta-ui-base/src/components/file-navigator.tsx
+++ b/packages/cta-ui-base/src/components/file-navigator.tsx
@@ -14,9 +14,12 @@ import { getFileClass, twClasses } from '../file-classes'
import FileViewer from './file-viewer'
import FileTree from './file-tree'
+import WebContainerProvider from './web-container-provider'
+import { WebContainerPreview } from './webcontainer-preview'
import { Label } from './ui/label'
import { Switch } from './ui/switch'
+import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs'
import type { FileTreeItem } from '../types'
@@ -181,27 +184,69 @@ export default function FileNavigator() {
const ready = useReady()
+ // Prepare project files for WebContainer
+ const webContainerFiles = useMemo(() => {
+ console.log('Preparing WebContainer files, tree:', tree)
+ if (!tree) {
+ console.log('Tree is empty, returning empty array')
+ return []
+ }
+ const files = Object.entries(tree).map(([path, content]) => ({
+ path,
+ content,
+ }))
+ console.log('WebContainer files prepared:', files.length, 'files')
+ return files
+ }, [tree])
+
if (!ready) {
return null
}
return (
-
- {mode === 'add' &&
}
-
-
-
-
-
- {selectedFile && modifiedFileContents ? (
-
- ) : null}
-
+
+
+ {mode === 'add' &&
}
+
+
+
+ Files
+
+
+ Preview
+
+
+
+
+
+
+
+
+
+ {selectedFile && modifiedFileContents ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
-
+
)
}
diff --git a/packages/cta-ui-base/src/components/file-viewer.tsx b/packages/cta-ui-base/src/components/file-viewer.tsx
index 87a21e7e..ee816622 100644
--- a/packages/cta-ui-base/src/components/file-viewer.tsx
+++ b/packages/cta-ui-base/src/components/file-viewer.tsx
@@ -45,10 +45,18 @@ export default function FileViewer({
}
const language = getLanguage(filePath)
- if (!originalFile || originalFile === modifiedFile) {
+ // Display placeholder for binary files
+ const displayModified = modifiedFile.startsWith('base64::')
+ ? '
'
+ : modifiedFile
+ const displayOriginal = originalFile?.startsWith('base64::')
+ ? ''
+ : originalFile
+
+ if (!displayOriginal || displayOriginal === displayModified) {
return (
-
-
+
+
)
}
diff --git a/packages/cta-ui-base/src/components/web-container-provider.tsx b/packages/cta-ui-base/src/components/web-container-provider.tsx
new file mode 100644
index 00000000..a843b847
--- /dev/null
+++ b/packages/cta-ui-base/src/components/web-container-provider.tsx
@@ -0,0 +1,42 @@
+import { createContext, useEffect, useState } from 'react'
+import { useStore } from 'zustand'
+import createWebContainerStore from '../hooks/use-webcontainer-store'
+
+export const WebContainerContext = createContext | null>(null)
+
+export default function WebContainerProvider({
+ children,
+ projectFiles,
+}: {
+ children: React.ReactNode
+ projectFiles: Array<{ path: string; content: string }>
+}) {
+ console.log(
+ 'WebContainerProvider rendering with',
+ projectFiles.length,
+ 'files',
+ )
+ const [containerStore] = useState(() => createWebContainerStore(true))
+
+ const updateProjectFiles = useStore(
+ containerStore,
+ (state) => state.updateProjectFiles,
+ )
+
+ useEffect(() => {
+ console.log(
+ 'WebContainerProvider useEffect triggered with',
+ projectFiles.length,
+ 'files',
+ )
+ updateProjectFiles(projectFiles)
+ }, [updateProjectFiles, projectFiles])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/cta-ui-base/src/components/webcontainer-preview.tsx b/packages/cta-ui-base/src/components/webcontainer-preview.tsx
new file mode 100644
index 00000000..8b6d0ca8
--- /dev/null
+++ b/packages/cta-ui-base/src/components/webcontainer-preview.tsx
@@ -0,0 +1,241 @@
+import { useContext, useEffect, useRef, useState } from 'react'
+import { useStore } from 'zustand'
+import { ChevronDown, ChevronUp } from 'lucide-react'
+
+import type { SetupStep } from '../hooks/use-webcontainer-store'
+import { WebContainerContext } from './web-container-provider'
+
+export function WebContainerPreview() {
+ const containerStore = useContext(WebContainerContext)
+ if (!containerStore) {
+ throw new Error('WebContainerContext not found')
+ }
+
+ const webContainer = useStore(containerStore, (state) => state.webContainer)
+ const setupStep = useStore(containerStore, (state) => state.setupStep)
+ const statusMessage = useStore(containerStore, (state) => state.statusMessage)
+ const terminalOutput = useStore(
+ containerStore,
+ (state) => state.terminalOutput,
+ )
+ const error = useStore(containerStore, (state) => state.error)
+ const previewUrl = useStore(containerStore, (state) => state.previewUrl)
+ const startDevServer = useStore(
+ containerStore,
+ (state) => state.startDevServer,
+ )
+ const setTerminalOutput = useStore(
+ containerStore,
+ (state) => state.setTerminalOutput,
+ )
+
+ const [isTerminalOpen, setIsTerminalOpen] = useState(false)
+
+ // Auto-scroll terminal to bottom when new output arrives
+ const terminalRef = useRef(null)
+ useEffect(() => {
+ if (terminalRef.current) {
+ terminalRef.current.scrollTop = terminalRef.current.scrollHeight
+ }
+ }, [terminalOutput])
+
+ const getStepIcon = (step: SetupStep) => {
+ switch (step) {
+ case 'mounting':
+ return 'π'
+ case 'installing':
+ return 'π¦'
+ case 'starting':
+ return 'π'
+ case 'ready':
+ return 'β
'
+ case 'error':
+ return 'β'
+ }
+ }
+
+ const getStepColor = (step: SetupStep) => {
+ switch (step) {
+ case 'error':
+ return 'text-red-500'
+ case 'ready':
+ return 'text-green-500'
+ default:
+ return 'text-blue-500'
+ }
+ }
+
+ // Show progress dialog during setup (similar to "Creating Your Application")
+ if (
+ !webContainer ||
+ setupStep === 'error' ||
+ setupStep !== 'ready' ||
+ !previewUrl
+ ) {
+ return (
+
+
+
+ {setupStep === 'error' ? 'Setup Failed' : 'Preparing Preview'}
+
+
+ {setupStep === 'error' ? (
+
+
β
+
+ An error occurred
+
+
+ {error}
+
+
+
+ ) : (
+ <>
+ {/* Progress Steps */}
+
+
+
{getStepIcon('mounting')}
+
+ Mount Files
+
+ {(setupStep === 'installing' ||
+ setupStep === 'starting' ||
+ setupStep === 'ready') &&
+ 'β'}
+
+
+
{getStepIcon('installing')}
+
+ Install Dependencies
+
+ {(setupStep === 'starting' || setupStep === 'ready') && 'β'}
+
+
+
{getStepIcon('starting')}
+
+ Start Server
+
+ {setupStep === 'ready' && 'β'}
+
+
+
+ {/* Current status */}
+
+ {statusMessage || 'Preparing your application...'}
+
+ >
+ )}
+
+
+ )
+ }
+
+ // Show the running application with collapsible terminal
+ return (
+
+ {/* iframe with the running app */}
+
+
+ {/* Collapsible Terminal output */}
+
+
setIsTerminalOpen(!isTerminalOpen)}
+ >
+
+
+
+ Terminal Output
+
+ {setupStep === 'ready' && previewUrl && (
+
β Server Running
+ )}
+
+
e.stopPropagation()}
+ >
+ {previewUrl && (
+
+ )}
+
+
+
+
+ {isTerminalOpen && (
+
+ {terminalOutput.length > 0 ? (
+ terminalOutput.map((line, index) => (
+
+ {line}
+
+ ))
+ ) : (
+
No output yet...
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/packages/cta-ui-base/src/hooks/use-web-container.ts b/packages/cta-ui-base/src/hooks/use-web-container.ts
new file mode 100644
index 00000000..b7eb5b2b
--- /dev/null
+++ b/packages/cta-ui-base/src/hooks/use-web-container.ts
@@ -0,0 +1,7 @@
+import { useContext } from 'react'
+import { WebContainerContext } from '../components/web-container-provider'
+
+export function useWebContainer() {
+ const webContainer = useContext(WebContainerContext)
+ return webContainer
+}
diff --git a/packages/cta-ui-base/src/hooks/use-webcontainer-store.ts b/packages/cta-ui-base/src/hooks/use-webcontainer-store.ts
new file mode 100644
index 00000000..86d48a63
--- /dev/null
+++ b/packages/cta-ui-base/src/hooks/use-webcontainer-store.ts
@@ -0,0 +1,436 @@
+import { WebContainer } from '@webcontainer/api'
+import { createStore } from 'zustand'
+
+import shimALS from '../lib/als-shim'
+
+import type { FileSystemTree, WebContainerProcess } from '@webcontainer/api'
+
+export type SetupStep =
+ | 'mounting'
+ | 'installing'
+ | 'starting'
+ | 'ready'
+ | 'error'
+
+console.log('>>> startup')
+
+type WebContainerStore = {
+ webContainer: Promise | null
+ ready: boolean
+ setupStep: SetupStep
+ statusMessage: string
+ terminalOutput: Array
+ previewUrl: string | null
+ error: string | null
+ devProcess: WebContainerProcess | null
+ projectFiles: Array<{ path: string; content: string }>
+ isInstalling: boolean
+
+ teardown: () => void
+ updateProjectFiles: (
+ projectFiles: Array<{ path: string; content: string }>,
+ ) => Promise
+
+ startDevServer: () => Promise
+ addTerminalOutput: (output: string) => void
+ installDependencies: () => Promise
+ setTerminalOutput: (output: Array) => void
+}
+
+const processTerminalLine = (data: string): string => {
+ // Clean up terminal output - remove ANSI codes and control characters
+ let cleaned = data
+
+ // Remove all ANSI escape sequences (comprehensive)
+ cleaned = cleaned.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // Standard ANSI sequences
+ cleaned = cleaned.replace(/\u001b\][0-9;]*;[^\u0007]*\u0007/g, '') // OSC sequences
+ cleaned = cleaned.replace(/\u001b[=>]/g, '') // Other escape codes
+
+ // Remove carriage returns and other control characters
+ cleaned = cleaned.replace(/\r/g, '')
+ cleaned = cleaned.replace(
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g,
+ '',
+ )
+
+ // Remove spinner characters and progress bar artifacts
+ cleaned = cleaned.replace(/[β β β Ήβ Έβ Όβ ΄β ¦β §β β ]/g, '')
+ cleaned = cleaned.replace(/[ββββββ
ββββββββββ]/g, '')
+ cleaned = cleaned.replace(/[βββ]/g, '')
+
+ // Trim excessive whitespace
+ cleaned = cleaned.trim()
+
+ // Only return non-empty lines
+ return cleaned.length > 0 ? cleaned : ''
+}
+
+let webContainer: Promise | null = null
+
+export default function createWebContainerStore(shouldShimALS: boolean) {
+ if (!webContainer) {
+ webContainer = WebContainer.boot()
+ }
+
+ const store = createStore((set, get) => ({
+ webContainer,
+ ready: false,
+ setupStep: 'mounting',
+ statusMessage: '',
+ terminalOutput: [],
+ previewUrl: null,
+ error: null,
+ devProcess: null,
+ projectFiles: [],
+ isInstalling: false,
+
+ teardown: () => {
+ set({ webContainer: null, ready: false })
+ },
+ addTerminalOutput: (output: string) => {
+ set(({ terminalOutput }) => ({
+ terminalOutput: [...terminalOutput, output],
+ }))
+ },
+ setTerminalOutput: (output: string[]) => {
+ set({ terminalOutput: output })
+ },
+ startDevServer: async () => {
+ const { devProcess, webContainer, addTerminalOutput } = get()
+ if (!webContainer) {
+ throw new Error('WebContainer not found')
+ }
+
+ try {
+ const container = await webContainer
+ if (!container) {
+ throw new Error('WebContainer not found')
+ }
+ if (devProcess) {
+ console.log('Killing existing dev process...')
+ devProcess.kill()
+ set({ devProcess: null })
+ }
+
+ set({
+ setupStep: 'starting',
+ statusMessage: 'Starting development server...',
+ })
+ addTerminalOutput('π Starting dev server...')
+
+ // Wait for server to be ready (set up listener first)
+ container.on('server-ready', (port, url) => {
+ console.log('Server ready on port', port, 'at', url)
+ const currentState = get()
+ set({
+ previewUrl: url,
+ setupStep: 'ready',
+ statusMessage: 'Development server running',
+ terminalOutput: [
+ ...currentState.terminalOutput,
+ `β
Server ready at ${url}`,
+ ],
+ })
+ })
+
+ // Start the dev server
+ const newDevProcess = await container.spawn('pnpm', ['dev'])
+ set({ devProcess: newDevProcess })
+
+ newDevProcess.output.pipeTo(
+ new WritableStream({
+ write(data) {
+ const cleaned = processTerminalLine(data)
+ if (cleaned && cleaned.length > 3) {
+ console.log('[DEV]', cleaned)
+ const currentState = get()
+ set({
+ terminalOutput: [...currentState.terminalOutput, cleaned],
+ })
+ }
+ },
+ }),
+ )
+
+ // Check exit code
+ const exitCode = await newDevProcess.exit
+ if (exitCode !== 0) {
+ addTerminalOutput(`β Dev server exited with code ${exitCode}`)
+ set({ error: `Dev server exited with code ${exitCode}` })
+ }
+ } catch (error) {
+ console.error('Dev server start error:', error)
+ addTerminalOutput(`β Dev server error: ${(error as Error).message}`)
+ set({ error: (error as Error).message, setupStep: 'error' })
+ }
+ },
+ updateProjectFiles: async (
+ projectFiles: Array<{ path: string; content: string }>,
+ ) => {
+ const {
+ projectFiles: originalProjectFiles,
+ addTerminalOutput,
+ installDependencies,
+ webContainer,
+ } = get()
+
+ if (!webContainer) {
+ console.error('WebContainer not found in updateProjectFiles')
+ throw new Error('WebContainer not found')
+ }
+
+ try {
+ const container = await webContainer
+ if (!container) {
+ console.error('WebContainer resolved to null')
+ throw new Error('WebContainer not found')
+ }
+ console.log('WebContainer booted successfully!', container)
+ } catch (error) {
+ console.error('WebContainer boot failed:', error)
+ set({
+ error: `WebContainer boot failed: ${(error as Error).message}`,
+ setupStep: 'error',
+ })
+ throw error
+ }
+
+ const container = await webContainer
+
+ let packageJSONChanged = false
+ const binaryFiles: Record = {}
+ if (originalProjectFiles.length === 0) {
+ const fileSystemTree: FileSystemTree = {}
+ let base64FileCount = 0
+
+ for (const { path, content } of projectFiles) {
+ const cleanPath = path.replace(/^\.?\//, '')
+ const pathParts = cleanPath.split('/')
+
+ let current: any = fileSystemTree
+ for (let i = 0; i < pathParts.length - 1; i++) {
+ const part = pathParts[i]
+ if (!current[part]) {
+ current[part] = { directory: {} }
+ }
+ current = current[part].directory
+ }
+
+ const fileName = pathParts[pathParts.length - 1]
+
+ const adjustedContent = shouldShimALS
+ ? shimALS(fileName, content)
+ : content
+
+ if (adjustedContent.startsWith('base64::')) {
+ base64FileCount++
+ const base64Content = adjustedContent.replace('base64::', '')
+
+ try {
+ const base64Cleaned = base64Content.replace(/\s/g, '')
+ const binaryString = atob(base64Cleaned)
+ const bytes = new Uint8Array(binaryString.length)
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i) & 0xff
+ }
+ binaryFiles[cleanPath] = bytes
+ } catch (error) {
+ console.error(
+ `[BINARY ERROR] Failed to convert ${cleanPath}:`,
+ error,
+ )
+ }
+ } else {
+ current[fileName] = {
+ file: {
+ contents: String(adjustedContent),
+ },
+ }
+ }
+ }
+
+ // Write the binary files on their own since mount doesn't support binary files correctly
+ await container.mount(fileSystemTree)
+ for (const [path, bytes] of Object.entries(binaryFiles)) {
+ await container.fs.writeFile(path, bytes)
+ }
+ packageJSONChanged = true
+ } else {
+ const originalMap = new Map()
+ for (const { path, content } of originalProjectFiles) {
+ originalMap.set(path, content)
+ }
+ const newMap = new Map()
+ for (const { path, content } of projectFiles) {
+ newMap.set(path, content)
+ }
+
+ const changedOrNewFiles: Array<{ path: string; content: string }> = []
+ for (const { path, content } of projectFiles) {
+ if (!originalMap.has(path)) {
+ changedOrNewFiles.push({ path, content })
+ } else if (originalMap.get(path) !== content) {
+ changedOrNewFiles.push({ path, content })
+ }
+ }
+
+ const deletedFiles: string[] = []
+ for (const { path } of originalProjectFiles) {
+ if (!newMap.has(path)) {
+ deletedFiles.push(path)
+ }
+ }
+
+ if (changedOrNewFiles.length > 0 || deletedFiles.length > 0) {
+ // Kill dev server before updating files to avoid HMR issues
+ const { devProcess } = get()
+ if (devProcess) {
+ console.log('Stopping dev server before file update...')
+ addTerminalOutput(
+ 'βΈοΈ Stopping dev server before updating files...',
+ )
+ devProcess.kill()
+ set({ devProcess: null, previewUrl: null })
+ }
+
+ for (const { path, content } of changedOrNewFiles) {
+ await container.fs.writeFile(path, content)
+ }
+
+ for (const path of deletedFiles) {
+ await container.fs.rm(path)
+ }
+
+ addTerminalOutput('π Files updated successfully')
+
+ if (changedOrNewFiles.some(({ path }) => path === './package.json')) {
+ packageJSONChanged = true
+ }
+ }
+ }
+
+ set({ projectFiles })
+
+ if (packageJSONChanged) {
+ addTerminalOutput(
+ 'π¦ Package.json changed, reinstalling dependencies...',
+ )
+ await installDependencies()
+ }
+ },
+ installDependencies: async () => {
+ const { webContainer, addTerminalOutput, startDevServer, isInstalling } =
+ get()
+
+ if (isInstalling) {
+ console.log('Install already in progress, skipping')
+ return
+ }
+
+ if (!webContainer) {
+ throw new Error('WebContainer not found')
+ }
+
+ set({ isInstalling: true })
+
+ try {
+ const container = await webContainer
+ if (!container) {
+ set({ isInstalling: false })
+ throw new Error('WebContainer not found')
+ }
+
+ set({
+ setupStep: 'installing',
+ statusMessage: 'Installing dependencies...',
+ })
+
+ console.log('Starting pnpm install...')
+ addTerminalOutput('π¦ Running pnpm install...')
+ addTerminalOutput('β³ This may take a minute...')
+
+ let installProcess
+ try {
+ installProcess = await container.spawn('pnpm', ['install'])
+ console.log('pnpm install process spawned successfully')
+ } catch (spawnError) {
+ console.error('Failed to spawn pnpm install:', spawnError)
+ throw spawnError
+ }
+
+ let outputCount = 0
+ let lastProgressUpdate = Date.now()
+ let allOutput: string[] = []
+ let progressInterval = setInterval(() => {
+ const elapsed = Math.floor((Date.now() - lastProgressUpdate) / 1000)
+ console.log(
+ `[INSTALL] Still running... (${elapsed}s, ${outputCount} output chunks)`,
+ )
+ }, 5000)
+
+ installProcess.output.pipeTo(
+ new WritableStream({
+ write(data) {
+ outputCount++
+ allOutput.push(data)
+
+ const cleaned = processTerminalLine(data)
+
+ // Show meaningful output immediately
+ if (cleaned && cleaned.length > 3) {
+ const isImportant =
+ cleaned.includes('added') ||
+ cleaned.includes('removed') ||
+ cleaned.includes('changed') ||
+ cleaned.includes('audited') ||
+ cleaned.includes('packages') ||
+ cleaned.includes('error') ||
+ cleaned.includes('warn') ||
+ cleaned.includes('ERR') ||
+ cleaned.includes('FAIL')
+
+ if (isImportant) {
+ console.log('[INSTALL]', cleaned)
+ addTerminalOutput(cleaned)
+ if (isImportant && progressInterval) {
+ clearInterval(progressInterval)
+ progressInterval = undefined as any
+ }
+ }
+ }
+ },
+ }),
+ )
+
+ console.log('Waiting for install to complete...')
+ const installExitCode = await installProcess.exit
+ if (progressInterval) clearInterval(progressInterval)
+ console.log('Install exit code:', installExitCode)
+ console.log('Total output lines:', outputCount)
+
+ if (installExitCode !== 0) {
+ // Show all output for debugging
+ console.error('[INSTALL ERROR] All output:', allOutput.join('\n'))
+ const errorMsg = `pnpm install failed with exit code ${installExitCode}`
+ addTerminalOutput(`β ${errorMsg}`)
+ addTerminalOutput('π‘ Check console for detailed error output')
+ set({ error: errorMsg, setupStep: 'error' })
+ throw new Error(errorMsg)
+ }
+
+ addTerminalOutput('β
Dependencies installed successfully')
+
+ await startDevServer()
+ } catch (error) {
+ console.error('Install error:', error)
+ addTerminalOutput(`β Install error: ${(error as Error).message}`)
+ set({ error: (error as Error).message, setupStep: 'error' })
+ throw error
+ } finally {
+ set({ isInstalling: false })
+ }
+ },
+ }))
+
+ return store
+}
diff --git a/packages/cta-ui-base/src/index.ts b/packages/cta-ui-base/src/index.ts
index 1e185392..d9d0508b 100644
--- a/packages/cta-ui-base/src/index.ts
+++ b/packages/cta-ui-base/src/index.ts
@@ -16,8 +16,11 @@ import ModeSelector from './components/sidebar-items/mode-selector'
import TypescriptSwitch from './components/sidebar-items/typescript-switch'
import StarterDialog from './components/sidebar-items/starter'
import SidebarGroup from './components/sidebar-items/sidebar-group'
+import WebContainerProvider from './components/web-container-provider'
+import { WebContainerPreview } from './components/webcontainer-preview'
import { useApplicationMode, useManager, useReady } from './store/project'
+import { useWebContainer } from './hooks/use-web-container'
export {
FileNavigator,
@@ -36,9 +39,12 @@ export {
TypescriptSwitch,
StarterDialog,
SidebarGroup,
+ WebContainerProvider,
+ WebContainerPreview,
useApplicationMode,
useManager,
useReady,
+ useWebContainer,
}
-export default RootComponent
\ No newline at end of file
+export default RootComponent
diff --git a/packages/cta-ui-base/src/lib/als-shim.ts b/packages/cta-ui-base/src/lib/als-shim.ts
new file mode 100644
index 00000000..526c4bb4
--- /dev/null
+++ b/packages/cta-ui-base/src/lib/als-shim.ts
@@ -0,0 +1,125 @@
+const ALS_RESOLVER = `resolve: {
+ alias: {
+ 'node:async_hooks': '\\0virtual:async_hooks',
+ async_hooks: '\\0virtual:async_hooks',
+ },
+},`
+
+const ALS_SHIM = `export class AsyncLocalStorage {
+ constructor() {
+ // queue: array of { store, fn, resolve, reject }
+ this._queue = [];
+ this._running = false; // true while processing queue
+ this._currentStore = undefined; // store visible to getStore() while a run executes
+ }
+
+ /**
+ * run(store, callback, ...args) -> Promise
+ * Queues the callback to run with store as the current store. If the callback
+ * returns a Promise, the queue waits for it to settle before starting the next run.
+ */
+ run(store, callback, ...args) {
+ return new Promise((resolve, reject) => {
+ this._queue.push({
+ store,
+ fn: () => callback(...args),
+ resolve,
+ reject,
+ });
+ // start processing (if not already)
+ this._processQueue().catch((err) => {
+ // _processQueue shouldn't throw; but guard anyway.
+ console.error('SerialAsyncLocalStorage internal error:', err);
+ });
+ });
+ }
+
+ /**
+ * getStore() -> current store or undefined
+ * Returns the store of the currently executing run (or undefined if none).
+ */
+ getStore() {
+ return this._currentStore;
+ }
+
+ /**
+ * enterWith(store)
+ * Set the current store for the currently running task synchronously.
+ * Throws if there is no active run (this polyfill requires you to be inside a run).
+ */
+ enterWith(store) {
+ if (!this._running) {
+ throw new Error('enterWith() may be used only while a run is active.');
+ }
+ this._currentStore = store;
+ }
+
+ // internal: process queue serially
+ async _processQueue() {
+ if (this._running) return;
+ this._running = true;
+
+ while (this._queue.length) {
+ const { store, fn, resolve, reject } = this._queue.shift();
+ const prevStore = this._currentStore;
+ this._currentStore = store;
+
+ try {
+ const result = fn();
+ // await if callback returned a promise
+ const awaited = result instanceof Promise ? await result : result;
+ resolve(awaited);
+ } catch (err) {
+ reject(err);
+ } finally {
+ // restore previous store (if any)
+ this._currentStore = prevStore;
+ }
+ // loop continues to next queued task
+ }
+
+ this._running = false;
+ }
+}
+export default AsyncLocalStorage
+`
+
+const ALS_SHIM_LOADER = `
+function alsShim(): PluginOption {
+ return {
+ enforce: 'pre',
+ name: 'virtual-async-hooks',
+ config() {
+ return {
+ resolve: {
+ alias: {
+ // catch both forms
+ 'node:async_hooks': '\\0virtual:async_hooks',
+ async_hooks: '\\0virtual:async_hooks',
+ },
+ },
+ };
+ },
+ resolveId(id) {
+ if (id === '\\0virtual:async_hooks') return id;
+ },
+ load(id) {
+ if (id !== '\\0virtual:async_hooks') return null;
+
+ return \`${ALS_SHIM}\`;
+ },
+ };
+}
+`
+
+export default function shimALS(fileName: string, content: string) {
+ let adjustedContent = content
+ if (fileName === 'vite.config.ts') {
+ adjustedContent += ALS_SHIM_LOADER
+ adjustedContent = adjustedContent.replace(
+ 'plugins: [',
+ `${ALS_RESOLVER}plugins: [alsShim(),`,
+ )
+ }
+ return adjustedContent
+}
diff --git a/packages/cta-ui/lib/engine-handling/add-to-app-wrapper.ts b/packages/cta-ui/lib/engine-handling/add-to-app-wrapper.ts
index 7cd64f33..13762bb3 100644
--- a/packages/cta-ui/lib/engine-handling/add-to-app-wrapper.ts
+++ b/packages/cta-ui/lib/engine-handling/add-to-app-wrapper.ts
@@ -129,7 +129,8 @@ export async function addToAppWrapper(
writeConfigFileToEnvironment(environment, options)
environment.finishRun()
- output.files = cleanUpFiles(output.files, projectPath)
+ // Preserve base64 content for WebContainer, FileViewer will display placeholders
+ output.files = cleanUpFiles(output.files, projectPath, true)
output.deletedFiles = cleanUpFileArray(output.deletedFiles, projectPath)
return output
}
diff --git a/packages/cta-ui/lib/engine-handling/create-app-wrapper.ts b/packages/cta-ui/lib/engine-handling/create-app-wrapper.ts
index 6ad5b6e4..b8c9c34e 100644
--- a/packages/cta-ui/lib/engine-handling/create-app-wrapper.ts
+++ b/packages/cta-ui/lib/engine-handling/create-app-wrapper.ts
@@ -65,9 +65,11 @@ export async function createAppWrapper(
starter,
framework,
chosenAddOns,
- addOnOptions: (!projectOptions.addOnOptions || Object.keys(projectOptions.addOnOptions).length === 0)
- ? populateAddOnOptionsDefaults(chosenAddOns)
- : projectOptions.addOnOptions,
+ addOnOptions:
+ !projectOptions.addOnOptions ||
+ Object.keys(projectOptions.addOnOptions).length === 0
+ ? populateAddOnOptionsDefaults(chosenAddOns)
+ : projectOptions.addOnOptions,
}
function createEnvironment() {
@@ -113,7 +115,8 @@ export async function createAppWrapper(
} else {
await createApp(environment, options)
- output.files = cleanUpFiles(output.files, targetDir)
+ // Preserve base64 content for WebContainer, FileViewer will display placeholders
+ output.files = cleanUpFiles(output.files, targetDir, true)
output.deletedFiles = cleanUpFileArray(output.deletedFiles, targetDir)
return output
diff --git a/packages/cta-ui/lib/engine-handling/file-helpers.ts b/packages/cta-ui/lib/engine-handling/file-helpers.ts
index a5358c10..37f916bd 100644
--- a/packages/cta-ui/lib/engine-handling/file-helpers.ts
+++ b/packages/cta-ui/lib/engine-handling/file-helpers.ts
@@ -5,11 +5,13 @@ import { CONFIG_FILE } from '@tanstack/cta-engine'
export function cleanUpFiles(
files: Record,
targetDir?: string,
+ preserveBase64 = false,
) {
return Object.keys(files).reduce>((acc, file) => {
- const content = files[file].startsWith('base64::')
- ? ''
- : files[file]
+ const content =
+ files[file].startsWith('base64::') && !preserveBase64
+ ? ''
+ : files[file]
if (basename(file) !== CONFIG_FILE) {
acc[targetDir ? file.replace(targetDir, '.') : file] = content
}
diff --git a/packages/cta-ui/lib/engine-handling/generate-initial-payload.ts b/packages/cta-ui/lib/engine-handling/generate-initial-payload.ts
index a3c5542a..1dfac317 100644
--- a/packages/cta-ui/lib/engine-handling/generate-initial-payload.ts
+++ b/packages/cta-ui/lib/engine-handling/generate-initial-payload.ts
@@ -45,7 +45,11 @@ export async function generateInitialPayload() {
const localFiles =
applicationMode === 'add'
- ? await cleanUpFiles(await recursivelyGatherFiles(projectPath, false))
+ ? await cleanUpFiles(
+ await recursivelyGatherFiles(projectPath, false),
+ undefined,
+ true,
+ )
: {}
const forcedRouterMode = getForcedRouterMode()
diff --git a/packages/cta-ui/vite.config.ts b/packages/cta-ui/vite.config.ts
index d9885cb2..d3d50c0e 100644
--- a/packages/cta-ui/vite.config.ts
+++ b/packages/cta-ui/vite.config.ts
@@ -8,6 +8,10 @@ export default defineConfig({
server: {
open: true,
port: 3000,
+ headers: {
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ },
},
build: {
outDir: 'dist',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4b399f17..8f672667 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -480,6 +480,9 @@ importers:
'@uiw/react-codemirror':
specifier: ^4.23.10
version: 4.23.11(@babel/runtime@7.27.0)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.10)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.6)(codemirror@6.0.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@webcontainer/api':
+ specifier: ^1.3.5
+ version: 1.6.1
chalk:
specifier: ^5.4.1
version: 5.4.1
@@ -2221,6 +2224,9 @@ packages:
'@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
+ '@webcontainer/api@1.6.1':
+ resolution: {integrity: sha512-2RS2KiIw32BY1Icf6M1DvqSmcon9XICZCDgS29QJb2NmF12ZY2V5Ia+949hMKB3Wno+P/Y8W+sPP59PZeXSELg==}
+
'@yarnpkg/lockfile@1.1.0':
resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==}
@@ -6757,6 +6763,8 @@ snapshots:
'@vue/shared@3.5.13': {}
+ '@webcontainer/api@1.6.1': {}
+
'@yarnpkg/lockfile@1.1.0': {}
'@yarnpkg/parsers@3.0.2':