@tanstack/cta-ui-base 0.29.1 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,436 @@
1
+ import { WebContainer } from '@webcontainer/api'
2
+ import { createStore } from 'zustand'
3
+
4
+ import shimALS from '../lib/als-shim'
5
+
6
+ import type { FileSystemTree, WebContainerProcess } from '@webcontainer/api'
7
+
8
+ export type SetupStep =
9
+ | 'mounting'
10
+ | 'installing'
11
+ | 'starting'
12
+ | 'ready'
13
+ | 'error'
14
+
15
+ console.log('>>> startup')
16
+
17
+ type WebContainerStore = {
18
+ webContainer: Promise<WebContainer> | null
19
+ ready: boolean
20
+ setupStep: SetupStep
21
+ statusMessage: string
22
+ terminalOutput: Array<string>
23
+ previewUrl: string | null
24
+ error: string | null
25
+ devProcess: WebContainerProcess | null
26
+ projectFiles: Array<{ path: string; content: string }>
27
+ isInstalling: boolean
28
+
29
+ teardown: () => void
30
+ updateProjectFiles: (
31
+ projectFiles: Array<{ path: string; content: string }>,
32
+ ) => Promise<void>
33
+
34
+ startDevServer: () => Promise<void>
35
+ addTerminalOutput: (output: string) => void
36
+ installDependencies: () => Promise<void>
37
+ setTerminalOutput: (output: Array<string>) => void
38
+ }
39
+
40
+ const processTerminalLine = (data: string): string => {
41
+ // Clean up terminal output - remove ANSI codes and control characters
42
+ let cleaned = data
43
+
44
+ // Remove all ANSI escape sequences (comprehensive)
45
+ cleaned = cleaned.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // Standard ANSI sequences
46
+ cleaned = cleaned.replace(/\u001b\][0-9;]*;[^\u0007]*\u0007/g, '') // OSC sequences
47
+ cleaned = cleaned.replace(/\u001b[=>]/g, '') // Other escape codes
48
+
49
+ // Remove carriage returns and other control characters
50
+ cleaned = cleaned.replace(/\r/g, '')
51
+ cleaned = cleaned.replace(
52
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g,
53
+ '',
54
+ )
55
+
56
+ // Remove spinner characters and progress bar artifacts
57
+ cleaned = cleaned.replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '')
58
+ cleaned = cleaned.replace(/[▀▁▂▃▄▅▆▇█▉▊▋▌▍▎▏]/g, '')
59
+ cleaned = cleaned.replace(/[░▒▓]/g, '')
60
+
61
+ // Trim excessive whitespace
62
+ cleaned = cleaned.trim()
63
+
64
+ // Only return non-empty lines
65
+ return cleaned.length > 0 ? cleaned : ''
66
+ }
67
+
68
+ let webContainer: Promise<WebContainer> | null = null
69
+
70
+ export default function createWebContainerStore(shouldShimALS: boolean) {
71
+ if (!webContainer) {
72
+ webContainer = WebContainer.boot()
73
+ }
74
+
75
+ const store = createStore<WebContainerStore>((set, get) => ({
76
+ webContainer,
77
+ ready: false,
78
+ setupStep: 'mounting',
79
+ statusMessage: '',
80
+ terminalOutput: [],
81
+ previewUrl: null,
82
+ error: null,
83
+ devProcess: null,
84
+ projectFiles: [],
85
+ isInstalling: false,
86
+
87
+ teardown: () => {
88
+ set({ webContainer: null, ready: false })
89
+ },
90
+ addTerminalOutput: (output: string) => {
91
+ set(({ terminalOutput }) => ({
92
+ terminalOutput: [...terminalOutput, output],
93
+ }))
94
+ },
95
+ setTerminalOutput: (output: string[]) => {
96
+ set({ terminalOutput: output })
97
+ },
98
+ startDevServer: async () => {
99
+ const { devProcess, webContainer, addTerminalOutput } = get()
100
+ if (!webContainer) {
101
+ throw new Error('WebContainer not found')
102
+ }
103
+
104
+ try {
105
+ const container = await webContainer
106
+ if (!container) {
107
+ throw new Error('WebContainer not found')
108
+ }
109
+ if (devProcess) {
110
+ console.log('Killing existing dev process...')
111
+ devProcess.kill()
112
+ set({ devProcess: null })
113
+ }
114
+
115
+ set({
116
+ setupStep: 'starting',
117
+ statusMessage: 'Starting development server...',
118
+ })
119
+ addTerminalOutput('🚀 Starting dev server...')
120
+
121
+ // Wait for server to be ready (set up listener first)
122
+ container.on('server-ready', (port, url) => {
123
+ console.log('Server ready on port', port, 'at', url)
124
+ const currentState = get()
125
+ set({
126
+ previewUrl: url,
127
+ setupStep: 'ready',
128
+ statusMessage: 'Development server running',
129
+ terminalOutput: [
130
+ ...currentState.terminalOutput,
131
+ `✅ Server ready at ${url}`,
132
+ ],
133
+ })
134
+ })
135
+
136
+ // Start the dev server
137
+ const newDevProcess = await container.spawn('pnpm', ['dev'])
138
+ set({ devProcess: newDevProcess })
139
+
140
+ newDevProcess.output.pipeTo(
141
+ new WritableStream({
142
+ write(data) {
143
+ const cleaned = processTerminalLine(data)
144
+ if (cleaned && cleaned.length > 3) {
145
+ console.log('[DEV]', cleaned)
146
+ const currentState = get()
147
+ set({
148
+ terminalOutput: [...currentState.terminalOutput, cleaned],
149
+ })
150
+ }
151
+ },
152
+ }),
153
+ )
154
+
155
+ // Check exit code
156
+ const exitCode = await newDevProcess.exit
157
+ if (exitCode !== 0) {
158
+ addTerminalOutput(`❌ Dev server exited with code ${exitCode}`)
159
+ set({ error: `Dev server exited with code ${exitCode}` })
160
+ }
161
+ } catch (error) {
162
+ console.error('Dev server start error:', error)
163
+ addTerminalOutput(`❌ Dev server error: ${(error as Error).message}`)
164
+ set({ error: (error as Error).message, setupStep: 'error' })
165
+ }
166
+ },
167
+ updateProjectFiles: async (
168
+ projectFiles: Array<{ path: string; content: string }>,
169
+ ) => {
170
+ const {
171
+ projectFiles: originalProjectFiles,
172
+ addTerminalOutput,
173
+ installDependencies,
174
+ webContainer,
175
+ } = get()
176
+
177
+ if (!webContainer) {
178
+ console.error('WebContainer not found in updateProjectFiles')
179
+ throw new Error('WebContainer not found')
180
+ }
181
+
182
+ try {
183
+ const container = await webContainer
184
+ if (!container) {
185
+ console.error('WebContainer resolved to null')
186
+ throw new Error('WebContainer not found')
187
+ }
188
+ console.log('WebContainer booted successfully!', container)
189
+ } catch (error) {
190
+ console.error('WebContainer boot failed:', error)
191
+ set({
192
+ error: `WebContainer boot failed: ${(error as Error).message}`,
193
+ setupStep: 'error',
194
+ })
195
+ throw error
196
+ }
197
+
198
+ const container = await webContainer
199
+
200
+ let packageJSONChanged = false
201
+ const binaryFiles: Record<string, Uint8Array> = {}
202
+ if (originalProjectFiles.length === 0) {
203
+ const fileSystemTree: FileSystemTree = {}
204
+ let base64FileCount = 0
205
+
206
+ for (const { path, content } of projectFiles) {
207
+ const cleanPath = path.replace(/^\.?\//, '')
208
+ const pathParts = cleanPath.split('/')
209
+
210
+ let current: any = fileSystemTree
211
+ for (let i = 0; i < pathParts.length - 1; i++) {
212
+ const part = pathParts[i]
213
+ if (!current[part]) {
214
+ current[part] = { directory: {} }
215
+ }
216
+ current = current[part].directory
217
+ }
218
+
219
+ const fileName = pathParts[pathParts.length - 1]
220
+
221
+ const adjustedContent = shouldShimALS
222
+ ? shimALS(fileName, content)
223
+ : content
224
+
225
+ if (adjustedContent.startsWith('base64::')) {
226
+ base64FileCount++
227
+ const base64Content = adjustedContent.replace('base64::', '')
228
+
229
+ try {
230
+ const base64Cleaned = base64Content.replace(/\s/g, '')
231
+ const binaryString = atob(base64Cleaned)
232
+ const bytes = new Uint8Array(binaryString.length)
233
+ for (let i = 0; i < binaryString.length; i++) {
234
+ bytes[i] = binaryString.charCodeAt(i) & 0xff
235
+ }
236
+ binaryFiles[cleanPath] = bytes
237
+ } catch (error) {
238
+ console.error(
239
+ `[BINARY ERROR] Failed to convert ${cleanPath}:`,
240
+ error,
241
+ )
242
+ }
243
+ } else {
244
+ current[fileName] = {
245
+ file: {
246
+ contents: String(adjustedContent),
247
+ },
248
+ }
249
+ }
250
+ }
251
+
252
+ // Write the binary files on their own since mount doesn't support binary files correctly
253
+ await container.mount(fileSystemTree)
254
+ for (const [path, bytes] of Object.entries(binaryFiles)) {
255
+ await container.fs.writeFile(path, bytes)
256
+ }
257
+ packageJSONChanged = true
258
+ } else {
259
+ const originalMap = new Map<string, string>()
260
+ for (const { path, content } of originalProjectFiles) {
261
+ originalMap.set(path, content)
262
+ }
263
+ const newMap = new Map<string, string>()
264
+ for (const { path, content } of projectFiles) {
265
+ newMap.set(path, content)
266
+ }
267
+
268
+ const changedOrNewFiles: Array<{ path: string; content: string }> = []
269
+ for (const { path, content } of projectFiles) {
270
+ if (!originalMap.has(path)) {
271
+ changedOrNewFiles.push({ path, content })
272
+ } else if (originalMap.get(path) !== content) {
273
+ changedOrNewFiles.push({ path, content })
274
+ }
275
+ }
276
+
277
+ const deletedFiles: string[] = []
278
+ for (const { path } of originalProjectFiles) {
279
+ if (!newMap.has(path)) {
280
+ deletedFiles.push(path)
281
+ }
282
+ }
283
+
284
+ if (changedOrNewFiles.length > 0 || deletedFiles.length > 0) {
285
+ // Kill dev server before updating files to avoid HMR issues
286
+ const { devProcess } = get()
287
+ if (devProcess) {
288
+ console.log('Stopping dev server before file update...')
289
+ addTerminalOutput(
290
+ '⏸️ Stopping dev server before updating files...',
291
+ )
292
+ devProcess.kill()
293
+ set({ devProcess: null, previewUrl: null })
294
+ }
295
+
296
+ for (const { path, content } of changedOrNewFiles) {
297
+ await container.fs.writeFile(path, content)
298
+ }
299
+
300
+ for (const path of deletedFiles) {
301
+ await container.fs.rm(path)
302
+ }
303
+
304
+ addTerminalOutput('📁 Files updated successfully')
305
+
306
+ if (changedOrNewFiles.some(({ path }) => path === './package.json')) {
307
+ packageJSONChanged = true
308
+ }
309
+ }
310
+ }
311
+
312
+ set({ projectFiles })
313
+
314
+ if (packageJSONChanged) {
315
+ addTerminalOutput(
316
+ '📦 Package.json changed, reinstalling dependencies...',
317
+ )
318
+ await installDependencies()
319
+ }
320
+ },
321
+ installDependencies: async () => {
322
+ const { webContainer, addTerminalOutput, startDevServer, isInstalling } =
323
+ get()
324
+
325
+ if (isInstalling) {
326
+ console.log('Install already in progress, skipping')
327
+ return
328
+ }
329
+
330
+ if (!webContainer) {
331
+ throw new Error('WebContainer not found')
332
+ }
333
+
334
+ set({ isInstalling: true })
335
+
336
+ try {
337
+ const container = await webContainer
338
+ if (!container) {
339
+ set({ isInstalling: false })
340
+ throw new Error('WebContainer not found')
341
+ }
342
+
343
+ set({
344
+ setupStep: 'installing',
345
+ statusMessage: 'Installing dependencies...',
346
+ })
347
+
348
+ console.log('Starting pnpm install...')
349
+ addTerminalOutput('📦 Running pnpm install...')
350
+ addTerminalOutput('⏳ This may take a minute...')
351
+
352
+ let installProcess
353
+ try {
354
+ installProcess = await container.spawn('pnpm', ['install'])
355
+ console.log('pnpm install process spawned successfully')
356
+ } catch (spawnError) {
357
+ console.error('Failed to spawn pnpm install:', spawnError)
358
+ throw spawnError
359
+ }
360
+
361
+ let outputCount = 0
362
+ let lastProgressUpdate = Date.now()
363
+ let allOutput: string[] = []
364
+ let progressInterval = setInterval(() => {
365
+ const elapsed = Math.floor((Date.now() - lastProgressUpdate) / 1000)
366
+ console.log(
367
+ `[INSTALL] Still running... (${elapsed}s, ${outputCount} output chunks)`,
368
+ )
369
+ }, 5000)
370
+
371
+ installProcess.output.pipeTo(
372
+ new WritableStream({
373
+ write(data) {
374
+ outputCount++
375
+ allOutput.push(data)
376
+
377
+ const cleaned = processTerminalLine(data)
378
+
379
+ // Show meaningful output immediately
380
+ if (cleaned && cleaned.length > 3) {
381
+ const isImportant =
382
+ cleaned.includes('added') ||
383
+ cleaned.includes('removed') ||
384
+ cleaned.includes('changed') ||
385
+ cleaned.includes('audited') ||
386
+ cleaned.includes('packages') ||
387
+ cleaned.includes('error') ||
388
+ cleaned.includes('warn') ||
389
+ cleaned.includes('ERR') ||
390
+ cleaned.includes('FAIL')
391
+
392
+ if (isImportant) {
393
+ console.log('[INSTALL]', cleaned)
394
+ addTerminalOutput(cleaned)
395
+ if (isImportant && progressInterval) {
396
+ clearInterval(progressInterval)
397
+ progressInterval = undefined as any
398
+ }
399
+ }
400
+ }
401
+ },
402
+ }),
403
+ )
404
+
405
+ console.log('Waiting for install to complete...')
406
+ const installExitCode = await installProcess.exit
407
+ if (progressInterval) clearInterval(progressInterval)
408
+ console.log('Install exit code:', installExitCode)
409
+ console.log('Total output lines:', outputCount)
410
+
411
+ if (installExitCode !== 0) {
412
+ // Show all output for debugging
413
+ console.error('[INSTALL ERROR] All output:', allOutput.join('\n'))
414
+ const errorMsg = `pnpm install failed with exit code ${installExitCode}`
415
+ addTerminalOutput(`❌ ${errorMsg}`)
416
+ addTerminalOutput('💡 Check console for detailed error output')
417
+ set({ error: errorMsg, setupStep: 'error' })
418
+ throw new Error(errorMsg)
419
+ }
420
+
421
+ addTerminalOutput('✅ Dependencies installed successfully')
422
+
423
+ await startDevServer()
424
+ } catch (error) {
425
+ console.error('Install error:', error)
426
+ addTerminalOutput(`❌ Install error: ${(error as Error).message}`)
427
+ set({ error: (error as Error).message, setupStep: 'error' })
428
+ throw error
429
+ } finally {
430
+ set({ isInstalling: false })
431
+ }
432
+ },
433
+ }))
434
+
435
+ return store
436
+ }
package/src/index.ts CHANGED
@@ -16,8 +16,11 @@ import ModeSelector from './components/sidebar-items/mode-selector'
16
16
  import TypescriptSwitch from './components/sidebar-items/typescript-switch'
17
17
  import StarterDialog from './components/sidebar-items/starter'
18
18
  import SidebarGroup from './components/sidebar-items/sidebar-group'
19
+ import WebContainerProvider from './components/web-container-provider'
20
+ import { WebContainerPreview } from './components/webcontainer-preview'
19
21
 
20
22
  import { useApplicationMode, useManager, useReady } from './store/project'
23
+ import { useWebContainer } from './hooks/use-web-container'
21
24
 
22
25
  export {
23
26
  FileNavigator,
@@ -36,9 +39,12 @@ export {
36
39
  TypescriptSwitch,
37
40
  StarterDialog,
38
41
  SidebarGroup,
42
+ WebContainerProvider,
43
+ WebContainerPreview,
39
44
  useApplicationMode,
40
45
  useManager,
41
46
  useReady,
47
+ useWebContainer,
42
48
  }
43
49
 
44
- export default RootComponent
50
+ export default RootComponent
@@ -0,0 +1,125 @@
1
+ const ALS_RESOLVER = `resolve: {
2
+ alias: {
3
+ 'node:async_hooks': '\\0virtual:async_hooks',
4
+ async_hooks: '\\0virtual:async_hooks',
5
+ },
6
+ },`
7
+
8
+ const ALS_SHIM = `export class AsyncLocalStorage {
9
+ constructor() {
10
+ // queue: array of { store, fn, resolve, reject }
11
+ this._queue = [];
12
+ this._running = false; // true while processing queue
13
+ this._currentStore = undefined; // store visible to getStore() while a run executes
14
+ }
15
+
16
+ /**
17
+ * run(store, callback, ...args) -> Promise
18
+ * Queues the callback to run with store as the current store. If the callback
19
+ * returns a Promise, the queue waits for it to settle before starting the next run.
20
+ */
21
+ run(store, callback, ...args) {
22
+ return new Promise((resolve, reject) => {
23
+ this._queue.push({
24
+ store,
25
+ fn: () => callback(...args),
26
+ resolve,
27
+ reject,
28
+ });
29
+ // start processing (if not already)
30
+ this._processQueue().catch((err) => {
31
+ // _processQueue shouldn't throw; but guard anyway.
32
+ console.error('SerialAsyncLocalStorage internal error:', err);
33
+ });
34
+ });
35
+ }
36
+
37
+ /**
38
+ * getStore() -> current store or undefined
39
+ * Returns the store of the currently executing run (or undefined if none).
40
+ */
41
+ getStore() {
42
+ return this._currentStore;
43
+ }
44
+
45
+ /**
46
+ * enterWith(store)
47
+ * Set the current store for the currently running task synchronously.
48
+ * Throws if there is no active run (this polyfill requires you to be inside a run).
49
+ */
50
+ enterWith(store) {
51
+ if (!this._running) {
52
+ throw new Error('enterWith() may be used only while a run is active.');
53
+ }
54
+ this._currentStore = store;
55
+ }
56
+
57
+ // internal: process queue serially
58
+ async _processQueue() {
59
+ if (this._running) return;
60
+ this._running = true;
61
+
62
+ while (this._queue.length) {
63
+ const { store, fn, resolve, reject } = this._queue.shift();
64
+ const prevStore = this._currentStore;
65
+ this._currentStore = store;
66
+
67
+ try {
68
+ const result = fn();
69
+ // await if callback returned a promise
70
+ const awaited = result instanceof Promise ? await result : result;
71
+ resolve(awaited);
72
+ } catch (err) {
73
+ reject(err);
74
+ } finally {
75
+ // restore previous store (if any)
76
+ this._currentStore = prevStore;
77
+ }
78
+ // loop continues to next queued task
79
+ }
80
+
81
+ this._running = false;
82
+ }
83
+ }
84
+ export default AsyncLocalStorage
85
+ `
86
+
87
+ const ALS_SHIM_LOADER = `
88
+ function alsShim(): PluginOption {
89
+ return {
90
+ enforce: 'pre',
91
+ name: 'virtual-async-hooks',
92
+ config() {
93
+ return {
94
+ resolve: {
95
+ alias: {
96
+ // catch both forms
97
+ 'node:async_hooks': '\\0virtual:async_hooks',
98
+ async_hooks: '\\0virtual:async_hooks',
99
+ },
100
+ },
101
+ };
102
+ },
103
+ resolveId(id) {
104
+ if (id === '\\0virtual:async_hooks') return id;
105
+ },
106
+ load(id) {
107
+ if (id !== '\\0virtual:async_hooks') return null;
108
+
109
+ return \`${ALS_SHIM}\`;
110
+ },
111
+ };
112
+ }
113
+ `
114
+
115
+ export default function shimALS(fileName: string, content: string) {
116
+ let adjustedContent = content
117
+ if (fileName === 'vite.config.ts') {
118
+ adjustedContent += ALS_SHIM_LOADER
119
+ adjustedContent = adjustedContent.replace(
120
+ 'plugins: [',
121
+ `${ALS_RESOLVER}plugins: [alsShim(),`,
122
+ )
123
+ }
124
+ return adjustedContent
125
+ }