create-visualbuild-app 0.1.0 → 1.0.0

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.
Files changed (44) hide show
  1. package/README.md +306 -123
  2. package/docs/user-guide.md +759 -0
  3. package/package.json +92 -85
  4. package/scripts/create-builder-app.mjs +513 -501
  5. package/scripts/vb-dev.mjs +41 -32
  6. package/scripts/vb-generate.mjs +332 -224
  7. package/templates/default/app/.vercelignore +6 -0
  8. package/templates/default/app/README.md +56 -21
  9. package/templates/default/app/visualbuild/components.json +3 -0
  10. package/templates/default/app/visualbuild/config.d.ts +48 -0
  11. package/templates/default/app/visualbuild.config.ts +38 -0
  12. package/templates/default/builder/README.md +37 -21
  13. package/visual-app-builder/server/parseReactPage.ts +776 -571
  14. package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
  15. package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
  16. package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
  17. package/visual-app-builder/shared/generateReactSource.mjs +608 -443
  18. package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
  19. package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
  20. package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
  21. package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
  22. package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
  23. package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
  24. package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
  25. package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
  26. package/visual-app-builder/src/App.tsx +1090 -874
  27. package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
  28. package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
  30. package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
  31. package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
  32. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
  33. package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
  34. package/visual-app-builder/src/components/Topbar.tsx +257 -128
  35. package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
  36. package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
  37. package/visual-app-builder/src/index.css +383 -111
  38. package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
  39. package/visual-app-builder/src/theme.ts +71 -0
  40. package/visual-app-builder/src/types/index.ts +350 -261
  41. package/visual-app-builder/src/utils/codegen.ts +90 -66
  42. package/visual-app-builder/src/utils/deployment.ts +54 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
  44. package/visual-app-builder/vite.config.ts +1946 -1479
@@ -1,1479 +1,1946 @@
1
- import { execFile } from 'node:child_process'
2
- import { randomUUID } from 'node:crypto'
3
- import fs from 'node:fs/promises'
4
- import type { ServerResponse } from 'node:http'
5
- import path from 'node:path'
6
- import { promisify } from 'node:util'
7
- import { watch as watchFiles } from 'chokidar'
8
- import { defineConfig } from 'vite'
9
- import type { Plugin } from 'vite'
10
- import react from '@vitejs/plugin-react'
11
- import tailwindcss from '@tailwindcss/vite'
12
- import {
13
- parseReactPageSource,
14
- reconcileImportedTree,
15
- visualTreesEqual,
16
- type ImportedPageTree,
17
- type ImportedVisualNode,
18
- } from './server/parseReactPage'
19
-
20
- const execFileAsync = promisify(execFile)
21
- const builderPackageRoot = process.cwd()
22
- const projectRoot = path.resolve(
23
- process.env.VISUALBUILD_APP_DIR || builderPackageRoot
24
- )
25
- const builderRoot = path.resolve(builderPackageRoot, 'visual-app-builder')
26
- const generatedSourceRoot = path.resolve(projectRoot, 'src')
27
- const visualbuildRoot = path.resolve(projectRoot, 'visualbuild')
28
- const maxEditableFileBytes = 2 * 1024 * 1024
29
- const hiddenProjectEntries = new Set(['.git', 'node_modules'])
30
-
31
- type ProjectEntryKind = 'file' | 'directory' | 'symlink'
32
-
33
- interface ProjectEntry {
34
- name: string
35
- path: string
36
- kind: ProjectEntryKind
37
- }
38
-
39
- interface ManagedPage {
40
- id: string
41
- name: string
42
- route: string
43
- sourcePath?: string
44
- root: ImportedVisualNode
45
- components: ImportedVisualNode[]
46
- }
47
-
48
- interface ManagedProjectSchema {
49
- pages: ManagedPage[]
50
- [key: string]: unknown
51
- }
52
-
53
- interface ReverseSyncEvent {
54
- type: 'reverse-sync-success' | 'reverse-sync-error'
55
- path: string
56
- pageId?: string
57
- message: string
58
- }
59
-
60
- interface GeneratorDiagnostic {
61
- severity: 'error'
62
- phase: 'generate'
63
- code: string
64
- title: string
65
- message: string
66
- path: string
67
- suggestion?: string
68
- }
69
-
70
- interface GeneratorDiagnosticEvent extends GeneratorDiagnostic {
71
- type: 'generator-diagnostic'
72
- }
73
-
74
- interface FileSystemEvent {
75
- type: 'filesystem-change'
76
- path: string
77
- action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
78
- }
79
-
80
- interface ManagedPagesDeletedEvent {
81
- type: 'managed-pages-deleted'
82
- path: string
83
- pageIds: string[]
84
- message: string
85
- }
86
-
87
- interface ManagedPagePathChange {
88
- pageId: string
89
- previousPath: string
90
- sourcePath: string
91
- }
92
-
93
- interface ManagedPagesMovedEvent {
94
- type: 'managed-pages-moved'
95
- path: string
96
- targetPath: string
97
- pages: ManagedPagePathChange[]
98
- message: string
99
- }
100
-
101
- type VisualBuildEvent =
102
- | ReverseSyncEvent
103
- | GeneratorDiagnosticEvent
104
- | FileSystemEvent
105
- | ManagedPagesDeletedEvent
106
- | ManagedPagesMovedEvent
107
-
108
- function isGeneratedProjectPath(watchedPath: string) {
109
- const absolutePath = path.resolve(watchedPath)
110
-
111
- return [generatedSourceRoot, visualbuildRoot].some(
112
- (rootPath) =>
113
- absolutePath === rootPath || absolutePath.startsWith(`${rootPath}${path.sep}`)
114
- )
115
- }
116
-
117
- function sendJson(
118
- res: { statusCode: number; setHeader: (name: string, value: string) => void; end: (body?: string) => void },
119
- statusCode: number,
120
- payload: unknown
121
- ) {
122
- res.statusCode = statusCode
123
- res.setHeader('Content-Type', 'application/json')
124
- res.end(JSON.stringify(payload))
125
- }
126
-
127
- function getProjectPath(relativePath = '') {
128
- const normalizedInput = relativePath.replace(/\\/g, '/').trim()
129
-
130
- if (
131
- normalizedInput.startsWith('/') ||
132
- /^[a-zA-Z]:/.test(normalizedInput) ||
133
- normalizedInput.includes('\0')
134
- ) {
135
- throw new Error('Invalid project path')
136
- }
137
-
138
- const segments = normalizedInput
139
- .split('/')
140
- .filter((segment) => segment && segment !== '.')
141
-
142
- if (segments.some((segment) => segment === '..')) {
143
- throw new Error('Project path cannot leave the app root')
144
- }
145
-
146
- const absolutePath = path.resolve(projectRoot, ...segments)
147
- const relativeToRoot = path.relative(projectRoot, absolutePath)
148
-
149
- if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
150
- throw new Error('Project path cannot leave the app root')
151
- }
152
-
153
- return {
154
- absolutePath,
155
- relativePath: segments.join('/'),
156
- }
157
- }
158
-
159
- async function assertRealPathInsideProject(absolutePath: string) {
160
- const [realProjectRoot, realTarget] = await Promise.all([
161
- fs.realpath(projectRoot),
162
- fs.realpath(absolutePath),
163
- ])
164
- const relativeToRoot = path.relative(realProjectRoot, realTarget)
165
-
166
- if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
167
- throw new Error('Project path cannot leave the app root')
168
- }
169
- }
170
-
171
- function validateEntryName(name: unknown) {
172
- if (
173
- typeof name !== 'string' ||
174
- !name.trim() ||
175
- name === '.' ||
176
- name === '..' ||
177
- /[\\/:*?"<>|]/.test(name)
178
- ) {
179
- throw new Error('Enter a valid file or folder name')
180
- }
181
-
182
- return name.trim()
183
- }
184
-
185
- async function readJsonBody(req: NodeJS.ReadableStream) {
186
- let body = ''
187
-
188
- for await (const chunk of req) {
189
- body += String(chunk)
190
-
191
- if (body.length > maxEditableFileBytes * 2) {
192
- throw new Error('Request is too large')
193
- }
194
- }
195
-
196
- return body ? (JSON.parse(body) as Record<string, unknown>) : {}
197
- }
198
-
199
- async function listProjectDirectory(relativePath: string) {
200
- const projectPath = getProjectPath(relativePath)
201
- const stats = await fs.lstat(projectPath.absolutePath)
202
-
203
- if (!stats.isDirectory() || stats.isSymbolicLink()) {
204
- throw new Error('The selected path is not a directory')
205
- }
206
-
207
- await assertRealPathInsideProject(projectPath.absolutePath)
208
- const directoryEntries = await fs.readdir(projectPath.absolutePath, {
209
- withFileTypes: true,
210
- })
211
- const entries: ProjectEntry[] = directoryEntries
212
- .filter((entry) => !hiddenProjectEntries.has(entry.name))
213
- .map((entry) => {
214
- const entryPath = projectPath.relativePath
215
- ? `${projectPath.relativePath}/${entry.name}`
216
- : entry.name
217
- const kind: ProjectEntryKind = entry.isSymbolicLink()
218
- ? 'symlink'
219
- : entry.isDirectory()
220
- ? 'directory'
221
- : 'file'
222
-
223
- return { name: entry.name, path: entryPath, kind }
224
- })
225
- .sort((first, second) => {
226
- if (first.kind === 'directory' && second.kind !== 'directory') return -1
227
- if (first.kind !== 'directory' && second.kind === 'directory') return 1
228
- return first.name.localeCompare(second.name, undefined, {
229
- numeric: true,
230
- sensitivity: 'base',
231
- })
232
- })
233
-
234
- return {
235
- rootName: path.basename(projectRoot),
236
- path: projectPath.relativePath,
237
- entries,
238
- }
239
- }
240
-
241
- async function readProjectFile(relativePath: string) {
242
- const projectPath = getProjectPath(relativePath)
243
- const stats = await fs.lstat(projectPath.absolutePath)
244
-
245
- if (!stats.isFile() || stats.isSymbolicLink()) {
246
- throw new Error('The selected path is not an editable file')
247
- }
248
-
249
- await assertRealPathInsideProject(projectPath.absolutePath)
250
-
251
- if (stats.size > maxEditableFileBytes) {
252
- return {
253
- path: projectPath.relativePath,
254
- content: '',
255
- size: stats.size,
256
- isBinary: false,
257
- isTooLarge: true,
258
- }
259
- }
260
-
261
- const buffer = await fs.readFile(projectPath.absolutePath)
262
- const isBinary = buffer.subarray(0, 8000).includes(0)
263
-
264
- return {
265
- path: projectPath.relativePath,
266
- content: isBinary ? '' : buffer.toString('utf8'),
267
- size: stats.size,
268
- isBinary,
269
- isTooLarge: false,
270
- }
271
- }
272
-
273
- async function importPageFromSource(
274
- relativePath: string,
275
- name: string,
276
- route: string
277
- ) {
278
- const projectPath = getProjectPath(relativePath)
279
-
280
- if (
281
- !projectPath.relativePath.startsWith('src/') ||
282
- !/\.(jsx|tsx)$/i.test(projectPath.relativePath)
283
- ) {
284
- throw new Error('Pages must use a .jsx or .tsx file inside src')
285
- }
286
-
287
- const stats = await fs.lstat(projectPath.absolutePath)
288
-
289
- if (!stats.isFile() || stats.isSymbolicLink()) {
290
- throw new Error('Select a valid React source file')
291
- }
292
-
293
- await assertRealPathInsideProject(projectPath.absolutePath)
294
- const source = await fs.readFile(projectPath.absolutePath, 'utf8')
295
- const pageId = randomUUID()
296
- const converted = parseReactPageSource(source, projectPath.relativePath)
297
-
298
- return {
299
- id: pageId,
300
- name,
301
- route,
302
- sourcePath: projectPath.relativePath,
303
- root: {
304
- ...converted.root,
305
- id: `${pageId}:root`,
306
- label: 'Page root',
307
- children: [],
308
- },
309
- components: converted.components,
310
- }
311
- }
312
-
313
- function getErrorStatus(error: unknown) {
314
- const code =
315
- typeof error === 'object' && error !== null && 'code' in error
316
- ? String(error.code)
317
- : ''
318
-
319
- if (code === 'ENOENT') return 404
320
- if (code === 'EEXIST' || code === 'ENOTEMPTY') return 409
321
- return 400
322
- }
323
-
324
- const generatorDiagnosticMarker = 'VISUALBUILD_DIAGNOSTIC:'
325
-
326
- function getProcessOutput(error: unknown, key: 'stdout' | 'stderr') {
327
- if (
328
- typeof error !== 'object' ||
329
- error === null ||
330
- !(key in error)
331
- ) {
332
- return ''
333
- }
334
-
335
- return String(error[key] ?? '')
336
- }
337
-
338
- function parseGeneratorDiagnostic(
339
- error: unknown,
340
- reason: string
341
- ): GeneratorDiagnostic {
342
- const stderr = getProcessOutput(error, 'stderr')
343
- const diagnosticLine = stderr
344
- .split(/\r?\n/)
345
- .find((line) => line.startsWith(generatorDiagnosticMarker))
346
-
347
- if (diagnosticLine) {
348
- try {
349
- const diagnostic = JSON.parse(
350
- diagnosticLine.slice(generatorDiagnosticMarker.length)
351
- ) as Partial<GeneratorDiagnostic>
352
-
353
- if (
354
- diagnostic.severity === 'error' &&
355
- diagnostic.phase === 'generate' &&
356
- typeof diagnostic.code === 'string' &&
357
- typeof diagnostic.title === 'string' &&
358
- typeof diagnostic.message === 'string' &&
359
- typeof diagnostic.path === 'string'
360
- ) {
361
- return {
362
- severity: diagnostic.severity,
363
- phase: diagnostic.phase,
364
- code: diagnostic.code,
365
- title: diagnostic.title,
366
- message: diagnostic.message,
367
- path: diagnostic.path,
368
- suggestion:
369
- typeof diagnostic.suggestion === 'string'
370
- ? diagnostic.suggestion
371
- : undefined,
372
- }
373
- }
374
- } catch {
375
- // Fall through to a stable diagnostic if the child process output is malformed.
376
- }
377
- }
378
-
379
- return {
380
- severity: 'error',
381
- phase: 'generate',
382
- code: 'GENERATION_FAILED',
383
- title: 'React source generation failed',
384
- message:
385
- error instanceof Error
386
- ? error.message
387
- : `Failed to generate project files after ${reason}.`,
388
- path: 'visualbuild/pages.json',
389
- suggestion:
390
- 'Review the project schema and try again. The last valid files were kept.',
391
- }
392
- }
393
-
394
- function getGeneratorDiagnostic(error: unknown) {
395
- if (
396
- typeof error !== 'object' ||
397
- error === null ||
398
- !('diagnostic' in error)
399
- ) {
400
- return null
401
- }
402
-
403
- return error.diagnostic as GeneratorDiagnostic
404
- }
405
-
406
- function visualbuildDevApi(): Plugin {
407
- const pagesPath = path.resolve(projectRoot, 'visualbuild/pages.json')
408
- const runtimeClassesPath = path.resolve(
409
- builderRoot,
410
- '.visualbuild-runtime-classes.html'
411
- )
412
- const generatorPath = path.resolve(
413
- builderPackageRoot,
414
- 'scripts/vb-generate.mjs'
415
- )
416
-
417
- return {
418
- name: 'visualbuild-dev-api',
419
- async configResolved() {
420
- await fs.writeFile(
421
- runtimeClassesPath,
422
- await fs.readFile(runtimeClassesPath, 'utf8').catch(() => '')
423
- )
424
- },
425
- configureServer(server) {
426
- let suppressWatchUntil = 0
427
- let suppressReloadUntil = 0
428
- let generateTimer: ReturnType<typeof setTimeout> | null = null
429
- let managedDeletionQueue = Promise.resolve()
430
- const reverseSyncTimers = new Map<
431
- string,
432
- ReturnType<typeof setTimeout>
433
- >()
434
- const suppressedUnlinkPaths = new Map<string, number>()
435
- const eventClients = new Set<ServerResponse>()
436
- const hotChannel = server.ws as unknown as {
437
- send: (payload: unknown, data?: unknown) => void
438
- }
439
- const sendHotPayload = hotChannel.send.bind(hotChannel)
440
-
441
- hotChannel.send = (payload, data) => {
442
- if (
443
- Date.now() < suppressReloadUntil &&
444
- typeof payload === 'object' &&
445
- payload !== null &&
446
- 'type' in payload &&
447
- payload.type === 'full-reload'
448
- ) {
449
- return
450
- }
451
-
452
- sendHotPayload(payload, data)
453
- }
454
-
455
- async function generateProjectFiles(reason: string) {
456
- try {
457
- const { stdout, stderr } = await execFileAsync(
458
- process.execPath,
459
- [generatorPath],
460
- { cwd: projectRoot }
461
- )
462
-
463
- if (stdout.trim()) {
464
- server.config.logger.info(stdout.trim())
465
- }
466
-
467
- if (stderr.trim()) {
468
- server.config.logger.warn(stderr.trim())
469
- }
470
- } catch (error) {
471
- const diagnostic = parseGeneratorDiagnostic(error, reason)
472
- server.config.logger.error(
473
- `[VisualBuild] ${diagnostic.title}: ${diagnostic.message}`
474
- )
475
- broadcastVisualBuildEvent({
476
- type: 'generator-diagnostic',
477
- ...diagnostic,
478
- })
479
-
480
- const generationError = new Error(diagnostic.message)
481
- Object.assign(generationError, { diagnostic })
482
- throw generationError
483
- }
484
- }
485
-
486
- const pagesWatcher = watchFiles(pagesPath, {
487
- ignoreInitial: true,
488
- usePolling: process.platform === 'win32',
489
- interval: 100,
490
- awaitWriteFinish: {
491
- stabilityThreshold: 120,
492
- pollInterval: 25,
493
- },
494
- }).on('change', () => {
495
- if (Date.now() < suppressWatchUntil) {
496
- return
497
- }
498
-
499
- if (generateTimer) {
500
- clearTimeout(generateTimer)
501
- }
502
-
503
- generateTimer = setTimeout(() => {
504
- generateTimer = null
505
- void generateProjectFiles('pages.json change').catch(() => {})
506
- }, 80)
507
- })
508
-
509
- function broadcastVisualBuildEvent(event: VisualBuildEvent) {
510
- const payload = `event: visualbuild\ndata: ${JSON.stringify(event)}\n\n`
511
-
512
- for (const client of eventClients) {
513
- client.write(payload)
514
- }
515
- }
516
-
517
- function normalizeManagedPath(value: unknown) {
518
- return typeof value === 'string'
519
- ? value.replace(/\\/g, '/').replace(/^\.\/+/, '')
520
- : ''
521
- }
522
-
523
- function findManagedPagesForPath(
524
- schema: ManagedProjectSchema,
525
- relativePath: string,
526
- isDirectory: boolean
527
- ) {
528
- const normalizedTarget = normalizeManagedPath(relativePath)
529
-
530
- return schema.pages.filter((page) => {
531
- const sourcePath = normalizeManagedPath(page.sourcePath)
532
-
533
- return (
534
- sourcePath === normalizedTarget ||
535
- (isDirectory && sourcePath.startsWith(`${normalizedTarget}/`))
536
- )
537
- })
538
- }
539
-
540
- function replaceManagedPathPrefix(
541
- sourcePath: string,
542
- previousPath: string,
543
- targetPath: string
544
- ) {
545
- return `${targetPath}${sourcePath.slice(previousPath.length)}`
546
- }
547
-
548
- function validateManagedPagePaths(schema: ManagedProjectSchema) {
549
- const seenSourcePaths = new Set<string>()
550
-
551
- for (const page of schema.pages) {
552
- const sourcePath = normalizeManagedPath(page.sourcePath)
553
-
554
- if (
555
- !sourcePath.startsWith('src/') ||
556
- !/\.(jsx|tsx)$/i.test(sourcePath) ||
557
- sourcePath.includes('/../') ||
558
- sourcePath.endsWith('/..')
559
- ) {
560
- throw new Error(
561
- `Managed page "${page.name}" must remain a .jsx or .tsx file inside src`
562
- )
563
- }
564
-
565
- if (
566
- sourcePath === 'src/App.tsx' ||
567
- sourcePath === 'src/layouts/AppLayout.tsx'
568
- ) {
569
- throw new Error(`Managed page path is reserved: ${sourcePath}`)
570
- }
571
-
572
- if (seenSourcePaths.has(sourcePath)) {
573
- throw new Error(`Duplicate managed page path: ${sourcePath}`)
574
- }
575
-
576
- seenSourcePaths.add(sourcePath)
577
- }
578
- }
579
-
580
- function suppressManagedUnlink(relativePath: string) {
581
- suppressedUnlinkPaths.set(
582
- normalizeManagedPath(relativePath),
583
- Date.now() + 2500
584
- )
585
- }
586
-
587
- function consumeSuppressedUnlink(relativePath: string) {
588
- const normalizedPath = normalizeManagedPath(relativePath)
589
- const expiresAt = suppressedUnlinkPaths.get(normalizedPath)
590
-
591
- if (!expiresAt) return false
592
-
593
- suppressedUnlinkPaths.delete(normalizedPath)
594
- return expiresAt >= Date.now()
595
- }
596
-
597
- async function persistManagedPageDeletion(
598
- relativePath: string,
599
- isDirectory: boolean,
600
- deleteTarget?: () => Promise<void>
601
- ) {
602
- const schema = JSON.parse(
603
- await fs.readFile(pagesPath, 'utf8')
604
- ) as ManagedProjectSchema
605
- const removedPages = findManagedPagesForPath(
606
- schema,
607
- relativePath,
608
- isDirectory
609
- )
610
-
611
- if (removedPages.length === 0) {
612
- if (deleteTarget) await deleteTarget()
613
- return []
614
- }
615
-
616
- if (removedPages.length === schema.pages.length) {
617
- throw new Error(
618
- 'A VisualBuild project must keep at least one registered page'
619
- )
620
- }
621
-
622
- const removedPageIds = new Set(removedPages.map((page) => page.id))
623
- const nextSchema: ManagedProjectSchema = {
624
- ...schema,
625
- pages: schema.pages.filter((page) => !removedPageIds.has(page.id)),
626
- }
627
-
628
- for (const page of removedPages) {
629
- const sourcePath = normalizeManagedPath(page.sourcePath)
630
-
631
- if (sourcePath) {
632
- suppressManagedUnlink(sourcePath)
633
- const pendingTimer = reverseSyncTimers.get(
634
- path.resolve(projectRoot, sourcePath)
635
- )
636
-
637
- if (pendingTimer) {
638
- clearTimeout(pendingTimer)
639
- reverseSyncTimers.delete(path.resolve(projectRoot, sourcePath))
640
- }
641
- }
642
- }
643
-
644
- suppressWatchUntil = Date.now() + 1500
645
- await fs.writeFile(
646
- pagesPath,
647
- `${JSON.stringify(nextSchema, null, 2)}\n`,
648
- 'utf8'
649
- )
650
-
651
- try {
652
- if (deleteTarget) await deleteTarget()
653
- } catch (error) {
654
- suppressWatchUntil = Date.now() + 1500
655
- await fs.writeFile(
656
- pagesPath,
657
- `${JSON.stringify(schema, null, 2)}\n`,
658
- 'utf8'
659
- )
660
- throw error
661
- }
662
-
663
- await generateProjectFiles('managed page deletion')
664
- broadcastVisualBuildEvent({
665
- type: 'managed-pages-deleted',
666
- path: normalizeManagedPath(relativePath),
667
- pageIds: removedPages.map((page) => page.id),
668
- message:
669
- removedPages.length === 1
670
- ? `Deleted ${removedPages[0].name}`
671
- : `Deleted ${removedPages.length} pages`,
672
- })
673
-
674
- return removedPages
675
- }
676
-
677
- async function persistManagedPageMove(
678
- relativePath: string,
679
- targetRelativePath: string,
680
- isDirectory: boolean,
681
- moveTarget: () => Promise<void>,
682
- rollbackTarget: () => Promise<void>
683
- ) {
684
- const normalizedSource = normalizeManagedPath(relativePath)
685
- const normalizedTarget = normalizeManagedPath(targetRelativePath)
686
- const schema = JSON.parse(
687
- await fs.readFile(pagesPath, 'utf8')
688
- ) as ManagedProjectSchema
689
- const affectedPages = findManagedPagesForPath(
690
- schema,
691
- normalizedSource,
692
- isDirectory
693
- )
694
-
695
- if (affectedPages.length === 0) {
696
- await moveTarget()
697
- return []
698
- }
699
-
700
- const affectedPageIds = new Set(affectedPages.map((page) => page.id))
701
- const pathChanges: ManagedPagePathChange[] = affectedPages.map(
702
- (page) => {
703
- const previousPath = normalizeManagedPath(page.sourcePath)
704
-
705
- return {
706
- pageId: page.id,
707
- previousPath,
708
- sourcePath: isDirectory
709
- ? replaceManagedPathPrefix(
710
- previousPath,
711
- normalizedSource,
712
- normalizedTarget
713
- )
714
- : normalizedTarget,
715
- }
716
- }
717
- )
718
- const nextPaths = new Map(
719
- pathChanges.map((change) => [change.pageId, change.sourcePath])
720
- )
721
- const nextSchema: ManagedProjectSchema = {
722
- ...schema,
723
- pages: schema.pages.map((page) =>
724
- affectedPageIds.has(page.id)
725
- ? { ...page, sourcePath: nextPaths.get(page.id) }
726
- : page
727
- ),
728
- }
729
-
730
- validateManagedPagePaths(nextSchema)
731
-
732
- for (const change of pathChanges) {
733
- suppressManagedUnlink(change.previousPath)
734
- const previousAbsolutePath = path.resolve(
735
- projectRoot,
736
- change.previousPath
737
- )
738
- const pendingTimer = reverseSyncTimers.get(previousAbsolutePath)
739
-
740
- if (pendingTimer) {
741
- clearTimeout(pendingTimer)
742
- reverseSyncTimers.delete(previousAbsolutePath)
743
- }
744
- }
745
-
746
- let targetMoved = false
747
- let schemaUpdated = false
748
-
749
- try {
750
- await moveTarget()
751
- targetMoved = true
752
- suppressWatchUntil = Date.now() + 1500
753
- await fs.writeFile(
754
- pagesPath,
755
- `${JSON.stringify(nextSchema, null, 2)}\n`,
756
- 'utf8'
757
- )
758
- schemaUpdated = true
759
- await generateProjectFiles('managed page move')
760
- } catch (error) {
761
- suppressWatchUntil = Date.now() + 1500
762
-
763
- if (schemaUpdated) {
764
- await fs.writeFile(
765
- pagesPath,
766
- `${JSON.stringify(schema, null, 2)}\n`,
767
- 'utf8'
768
- )
769
- }
770
-
771
- if (targetMoved) {
772
- await rollbackTarget()
773
- }
774
-
775
- if (schemaUpdated) {
776
- await generateProjectFiles('managed page move rollback').catch(
777
- () => {}
778
- )
779
- }
780
-
781
- throw error
782
- }
783
-
784
- broadcastVisualBuildEvent({
785
- type: 'managed-pages-moved',
786
- path: normalizedSource,
787
- targetPath: normalizedTarget,
788
- pages: pathChanges,
789
- message:
790
- pathChanges.length === 1
791
- ? `Moved ${affectedPages[0].name}`
792
- : `Moved ${pathChanges.length} managed pages`,
793
- })
794
-
795
- return pathChanges
796
- }
797
-
798
- function unregisterExternallyDeletedPage(relativePath: string) {
799
- managedDeletionQueue = managedDeletionQueue
800
- .then(async () => {
801
- const schema = JSON.parse(
802
- await fs.readFile(pagesPath, 'utf8')
803
- ) as ManagedProjectSchema
804
- const removedPages = findManagedPagesForPath(
805
- schema,
806
- relativePath,
807
- false
808
- )
809
-
810
- if (removedPages.length === 0) return
811
-
812
- if (removedPages.length === schema.pages.length) {
813
- await generateProjectFiles('restore required page')
814
- broadcastVisualBuildEvent({
815
- type: 'reverse-sync-error',
816
- path: relativePath,
817
- pageId: removedPages[0].id,
818
- message:
819
- 'The only registered page cannot be deleted. VisualBuild restored its source file.',
820
- })
821
- return
822
- }
823
-
824
- await persistManagedPageDeletion(relativePath, false)
825
- })
826
- .catch((error) => {
827
- const message =
828
- error instanceof Error
829
- ? error.message
830
- : 'Could not unregister the deleted page.'
831
- broadcastVisualBuildEvent({
832
- type: 'reverse-sync-error',
833
- path: relativePath,
834
- message,
835
- })
836
- server.config.logger.warn(
837
- `[VisualBuild] Page deletion sync skipped ${relativePath}: ${message}`
838
- )
839
- })
840
- }
841
-
842
- async function reverseSyncSourceFile(absolutePath: string) {
843
- const relativePath = path
844
- .relative(projectRoot, absolutePath)
845
- .split(path.sep)
846
- .join('/')
847
-
848
- try {
849
- const schema = JSON.parse(
850
- await fs.readFile(pagesPath, 'utf8')
851
- ) as ManagedProjectSchema
852
- const pageIndex = schema.pages.findIndex(
853
- (page) =>
854
- normalizeManagedPath(page.sourcePath) === relativePath
855
- )
856
-
857
- if (pageIndex < 0) return
858
-
859
- const page = schema.pages[pageIndex]
860
- const source = await fs.readFile(absolutePath, 'utf8')
861
- const imported = parseReactPageSource(source, relativePath)
862
- const current: ImportedPageTree = {
863
- root: page.root,
864
- components: page.components,
865
- }
866
- const reconciled = reconcileImportedTree(imported, current)
867
-
868
- if (visualTreesEqual(reconciled, current)) return
869
-
870
- schema.pages[pageIndex] = {
871
- ...page,
872
- root: reconciled.root,
873
- components: reconciled.components,
874
- }
875
- suppressWatchUntil = Date.now() + 1200
876
- await fs.writeFile(
877
- pagesPath,
878
- `${JSON.stringify(schema, null, 2)}\n`,
879
- 'utf8'
880
- )
881
- broadcastVisualBuildEvent({
882
- type: 'reverse-sync-success',
883
- path: relativePath,
884
- pageId: page.id,
885
- message: `Updated ${page.name} from ${relativePath}`,
886
- })
887
- server.config.logger.info(
888
- `[VisualBuild] Reverse synced ${relativePath}`
889
- )
890
- } catch (error) {
891
- const message =
892
- error instanceof Error
893
- ? error.message
894
- : 'Reverse sync could not parse the managed page.'
895
- broadcastVisualBuildEvent({
896
- type: 'reverse-sync-error',
897
- path: relativePath,
898
- message,
899
- })
900
- server.config.logger.warn(
901
- `[VisualBuild] Reverse sync skipped ${relativePath}: ${message}`
902
- )
903
- }
904
- }
905
-
906
- function scheduleReverseSync(absolutePath: string) {
907
- if (!/\.(jsx|tsx)$/i.test(absolutePath)) return
908
-
909
- const previousTimer = reverseSyncTimers.get(absolutePath)
910
- if (previousTimer) clearTimeout(previousTimer)
911
-
912
- reverseSyncTimers.set(
913
- absolutePath,
914
- setTimeout(() => {
915
- reverseSyncTimers.delete(absolutePath)
916
- void reverseSyncSourceFile(absolutePath)
917
- }, 100)
918
- )
919
- }
920
-
921
- function handleProjectFileChange(
922
- action: FileSystemEvent['action'],
923
- absolutePath: string
924
- ) {
925
- const relativePath = path
926
- .relative(projectRoot, absolutePath)
927
- .split(path.sep)
928
- .join('/')
929
-
930
- if (
931
- !relativePath ||
932
- relativePath === '..' ||
933
- relativePath.startsWith('../')
934
- ) {
935
- return
936
- }
937
-
938
- broadcastVisualBuildEvent({
939
- type: 'filesystem-change',
940
- path: relativePath,
941
- action,
942
- })
943
-
944
- const relativeToSource = path.relative(generatedSourceRoot, absolutePath)
945
- const isInsideSource =
946
- relativeToSource &&
947
- relativeToSource !== '..' &&
948
- !relativeToSource.startsWith(`..${path.sep}`) &&
949
- !path.isAbsolute(relativeToSource)
950
-
951
- if (isInsideSource && (action === 'add' || action === 'change')) {
952
- scheduleReverseSync(absolutePath)
953
- }
954
-
955
- if (
956
- isInsideSource &&
957
- action === 'unlink' &&
958
- !consumeSuppressedUnlink(relativePath)
959
- ) {
960
- unregisterExternallyDeletedPage(relativePath)
961
- }
962
- }
963
-
964
- const projectWatcher = watchFiles(projectRoot, {
965
- ignoreInitial: true,
966
- usePolling: process.platform === 'win32',
967
- interval: 100,
968
- ignored: (watchedPath) => {
969
- const relativePath = path.relative(projectRoot, path.resolve(watchedPath))
970
- const segments = relativePath.split(path.sep)
971
-
972
- return segments.some((segment) =>
973
- ['.git', 'node_modules', 'dist', '.visualbuild-qa'].includes(segment)
974
- )
975
- },
976
- awaitWriteFinish: {
977
- stabilityThreshold: 140,
978
- pollInterval: 25,
979
- },
980
- })
981
- .on('add', (changedPath) => handleProjectFileChange('add', changedPath))
982
- .on('change', (changedPath) =>
983
- handleProjectFileChange('change', changedPath)
984
- )
985
- .on('unlink', (changedPath) =>
986
- handleProjectFileChange('unlink', changedPath)
987
- )
988
- .on('addDir', (changedPath) =>
989
- handleProjectFileChange('addDir', changedPath)
990
- )
991
- .on('unlinkDir', (changedPath) =>
992
- handleProjectFileChange('unlinkDir', changedPath)
993
- )
994
- .on('ready', () => {
995
- server.config.logger.info(
996
- `[VisualBuild] Watching ${projectRoot} for project and managed source changes`
997
- )
998
- })
999
- .on('error', (error) => {
1000
- server.config.logger.warn(
1001
- `[VisualBuild] Source watcher error: ${error.message}`
1002
- )
1003
- })
1004
-
1005
- const keepAliveTimer = setInterval(() => {
1006
- for (const client of eventClients) {
1007
- client.write(': keep-alive\n\n')
1008
- }
1009
- }, 15000)
1010
-
1011
- server.httpServer?.once('close', () => {
1012
- void pagesWatcher.close()
1013
- void projectWatcher.close()
1014
- clearInterval(keepAliveTimer)
1015
-
1016
- for (const client of eventClients) {
1017
- client.end()
1018
- }
1019
-
1020
- for (const timer of reverseSyncTimers.values()) {
1021
- clearTimeout(timer)
1022
- }
1023
-
1024
- if (generateTimer) {
1025
- clearTimeout(generateTimer)
1026
- }
1027
- })
1028
-
1029
- void generateProjectFiles('dev server start').catch(() => {})
1030
-
1031
- server.middlewares.use('/__visualbuild/events', (req, res) => {
1032
- if (req.method !== 'GET') {
1033
- res.statusCode = 405
1034
- res.end()
1035
- return
1036
- }
1037
-
1038
- res.statusCode = 200
1039
- res.setHeader('Content-Type', 'text/event-stream')
1040
- res.setHeader('Cache-Control', 'no-cache, no-transform')
1041
- res.setHeader('Connection', 'keep-alive')
1042
- res.write('retry: 1500\n\n')
1043
- eventClients.add(res)
1044
-
1045
- req.once('close', () => {
1046
- eventClients.delete(res)
1047
- })
1048
- })
1049
-
1050
- server.middlewares.use('/__visualbuild/files', async (req, res) => {
1051
- const requestUrl = new URL(req.url ?? '/', 'http://visualbuild.local')
1052
-
1053
- try {
1054
- if (req.method === 'GET') {
1055
- const result = await listProjectDirectory(
1056
- requestUrl.searchParams.get('path') ?? ''
1057
- )
1058
- sendJson(res, 200, result)
1059
- return
1060
- }
1061
-
1062
- if (req.method === 'POST') {
1063
- const payload = await readJsonBody(req)
1064
- const parentPath = getProjectPath(
1065
- typeof payload.parentPath === 'string' ? payload.parentPath : ''
1066
- )
1067
- const parentStats = await fs.lstat(parentPath.absolutePath)
1068
-
1069
- if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) {
1070
- throw new Error('Select a valid parent folder')
1071
- }
1072
-
1073
- await assertRealPathInsideProject(parentPath.absolutePath)
1074
- const name = validateEntryName(payload.name)
1075
- const targetPath = getProjectPath(
1076
- parentPath.relativePath
1077
- ? `${parentPath.relativePath}/${name}`
1078
- : name
1079
- )
1080
-
1081
- if (payload.kind === 'directory') {
1082
- await fs.mkdir(targetPath.absolutePath)
1083
- } else if (payload.kind === 'file') {
1084
- await fs.writeFile(targetPath.absolutePath, '', {
1085
- encoding: 'utf8',
1086
- flag: 'wx',
1087
- })
1088
- } else {
1089
- throw new Error('Entry type must be file or directory')
1090
- }
1091
-
1092
- sendJson(res, 201, {
1093
- name,
1094
- path: targetPath.relativePath,
1095
- kind: payload.kind,
1096
- })
1097
- return
1098
- }
1099
-
1100
- if (req.method === 'PATCH') {
1101
- const payload = await readJsonBody(req)
1102
- const sourcePath = getProjectPath(
1103
- typeof payload.path === 'string' ? payload.path : ''
1104
- )
1105
-
1106
- if (!sourcePath.relativePath) {
1107
- throw new Error('The project root cannot be renamed')
1108
- }
1109
-
1110
- const sourceStats = await fs.lstat(sourcePath.absolutePath)
1111
-
1112
- if (!sourceStats.isSymbolicLink()) {
1113
- await assertRealPathInsideProject(sourcePath.absolutePath)
1114
- }
1115
-
1116
- const name = validateEntryName(payload.name)
1117
- const currentParentPath = path.posix.dirname(
1118
- sourcePath.relativePath
1119
- )
1120
- const requestedParentPath =
1121
- typeof payload.parentPath === 'string'
1122
- ? getProjectPath(payload.parentPath)
1123
- : getProjectPath(
1124
- currentParentPath === '.' ? '' : currentParentPath
1125
- )
1126
- const parentStats = await fs.lstat(
1127
- requestedParentPath.absolutePath
1128
- )
1129
-
1130
- if (
1131
- !parentStats.isDirectory() ||
1132
- parentStats.isSymbolicLink()
1133
- ) {
1134
- throw new Error('Select a valid destination folder')
1135
- }
1136
-
1137
- await assertRealPathInsideProject(
1138
- requestedParentPath.absolutePath
1139
- )
1140
- const targetPath = getProjectPath(
1141
- requestedParentPath.relativePath
1142
- ? `${requestedParentPath.relativePath}/${name}`
1143
- : name
1144
- )
1145
-
1146
- if (targetPath.relativePath === sourcePath.relativePath) {
1147
- sendJson(res, 200, {
1148
- name,
1149
- path: targetPath.relativePath,
1150
- kind: sourceStats.isSymbolicLink()
1151
- ? 'symlink'
1152
- : sourceStats.isDirectory()
1153
- ? 'directory'
1154
- : 'file',
1155
- managedPages: [],
1156
- })
1157
- return
1158
- }
1159
-
1160
- if (
1161
- sourceStats.isDirectory() &&
1162
- targetPath.relativePath.startsWith(
1163
- `${sourcePath.relativePath}/`
1164
- )
1165
- ) {
1166
- throw new Error('A folder cannot be moved inside itself')
1167
- }
1168
-
1169
- const targetExists = await fs
1170
- .lstat(targetPath.absolutePath)
1171
- .then(() => true)
1172
- .catch((error: NodeJS.ErrnoException) => {
1173
- if (error.code === 'ENOENT') return false
1174
- throw error
1175
- })
1176
-
1177
- if (targetExists) {
1178
- throw new Error('A file or folder with that name already exists')
1179
- }
1180
-
1181
- const managedPages = await persistManagedPageMove(
1182
- sourcePath.relativePath,
1183
- targetPath.relativePath,
1184
- sourceStats.isDirectory() && !sourceStats.isSymbolicLink(),
1185
- () =>
1186
- fs.rename(sourcePath.absolutePath, targetPath.absolutePath),
1187
- () =>
1188
- fs.rename(targetPath.absolutePath, sourcePath.absolutePath)
1189
- )
1190
- sendJson(res, 200, {
1191
- name,
1192
- path: targetPath.relativePath,
1193
- kind: sourceStats.isSymbolicLink()
1194
- ? 'symlink'
1195
- : sourceStats.isDirectory()
1196
- ? 'directory'
1197
- : 'file',
1198
- managedPages,
1199
- })
1200
- return
1201
- }
1202
-
1203
- if (req.method === 'DELETE') {
1204
- const targetPath = getProjectPath(
1205
- requestUrl.searchParams.get('path') ?? ''
1206
- )
1207
-
1208
- if (!targetPath.relativePath) {
1209
- throw new Error('The project root cannot be deleted')
1210
- }
1211
-
1212
- const targetStats = await fs.lstat(targetPath.absolutePath)
1213
-
1214
- if (!targetStats.isSymbolicLink()) {
1215
- await assertRealPathInsideProject(targetPath.absolutePath)
1216
- }
1217
-
1218
- const deleteTarget = () =>
1219
- fs.rm(targetPath.absolutePath, {
1220
- recursive: targetStats.isDirectory(),
1221
- })
1222
- const removedPages = await persistManagedPageDeletion(
1223
- targetPath.relativePath,
1224
- targetStats.isDirectory(),
1225
- deleteTarget
1226
- )
1227
-
1228
- sendJson(res, 200, {
1229
- path: targetPath.relativePath,
1230
- removedPageIds: removedPages.map((page) => page.id),
1231
- })
1232
- return
1233
- }
1234
-
1235
- res.statusCode = 405
1236
- res.end()
1237
- } catch (error) {
1238
- sendJson(res, getErrorStatus(error), {
1239
- error:
1240
- error instanceof Error
1241
- ? error.message
1242
- : 'Project file operation failed',
1243
- })
1244
- }
1245
- })
1246
-
1247
- server.middlewares.use('/__visualbuild/file', async (req, res) => {
1248
- const requestUrl = new URL(req.url ?? '/', 'http://visualbuild.local')
1249
-
1250
- try {
1251
- if (req.method === 'GET') {
1252
- sendJson(
1253
- res,
1254
- 200,
1255
- await readProjectFile(requestUrl.searchParams.get('path') ?? '')
1256
- )
1257
- return
1258
- }
1259
-
1260
- if (req.method === 'PUT') {
1261
- const payload = await readJsonBody(req)
1262
- const targetPath = getProjectPath(
1263
- typeof payload.path === 'string' ? payload.path : ''
1264
- )
1265
- const stats = await fs.lstat(targetPath.absolutePath)
1266
-
1267
- if (!stats.isFile() || stats.isSymbolicLink()) {
1268
- throw new Error('The selected path is not an editable file')
1269
- }
1270
-
1271
- await assertRealPathInsideProject(targetPath.absolutePath)
1272
-
1273
- if (typeof payload.content !== 'string') {
1274
- throw new Error('File content must be text')
1275
- }
1276
-
1277
- if (Buffer.byteLength(payload.content, 'utf8') > maxEditableFileBytes) {
1278
- throw new Error('File content exceeds the 2 MB editor limit')
1279
- }
1280
-
1281
- await fs.writeFile(targetPath.absolutePath, payload.content, 'utf8')
1282
- res.statusCode = 204
1283
- res.end()
1284
- return
1285
- }
1286
-
1287
- res.statusCode = 405
1288
- res.end()
1289
- } catch (error) {
1290
- sendJson(res, getErrorStatus(error), {
1291
- error:
1292
- error instanceof Error
1293
- ? error.message
1294
- : 'Project file operation failed',
1295
- })
1296
- }
1297
- })
1298
-
1299
- server.middlewares.use('/__visualbuild/import-page', async (req, res) => {
1300
- try {
1301
- if (req.method !== 'POST') {
1302
- res.statusCode = 405
1303
- res.end()
1304
- return
1305
- }
1306
-
1307
- const payload = await readJsonBody(req)
1308
- const name =
1309
- typeof payload.name === 'string' ? payload.name.trim() : ''
1310
- const route =
1311
- typeof payload.route === 'string' ? payload.route.trim() : ''
1312
- const sourcePath =
1313
- typeof payload.path === 'string' ? payload.path : ''
1314
-
1315
- if (!name) {
1316
- throw new Error('Page name is required')
1317
- }
1318
-
1319
- if (!route.startsWith('/')) {
1320
- throw new Error('Page route must start with "/"')
1321
- }
1322
-
1323
- sendJson(
1324
- res,
1325
- 201,
1326
- await importPageFromSource(sourcePath, name, route)
1327
- )
1328
- } catch (error) {
1329
- sendJson(res, getErrorStatus(error), {
1330
- error:
1331
- error instanceof Error
1332
- ? error.message
1333
- : 'Failed to import page source',
1334
- })
1335
- }
1336
- })
1337
-
1338
- server.middlewares.use('/__visualbuild/tailwind', (req, res) => {
1339
- if (req.method !== 'POST') {
1340
- res.statusCode = 405
1341
- res.end()
1342
- return
1343
- }
1344
-
1345
- let body = ''
1346
-
1347
- req.on('data', (chunk) => {
1348
- body += chunk
1349
- })
1350
-
1351
- req.on('end', async () => {
1352
- try {
1353
- const current = await fs
1354
- .readFile(runtimeClassesPath, 'utf8')
1355
- .catch(() => '')
1356
-
1357
- if (current !== body) {
1358
- suppressReloadUntil = Date.now() + 1500
1359
- await fs.writeFile(runtimeClassesPath, body)
1360
- }
1361
-
1362
- res.statusCode = 204
1363
- res.end()
1364
- } catch (error) {
1365
- res.statusCode = 500
1366
- res.end(
1367
- error instanceof Error
1368
- ? error.message
1369
- : 'Failed to update Tailwind classes'
1370
- )
1371
- }
1372
- })
1373
- })
1374
-
1375
- server.middlewares.use('/__visualbuild/pages', async (req, res) => {
1376
- if (req.method === 'GET') {
1377
- try {
1378
- const json = await fs.readFile(pagesPath, 'utf8')
1379
- res.setHeader('Content-Type', 'application/json')
1380
- res.setHeader(
1381
- 'X-VisualBuild-Project-Id',
1382
- encodeURIComponent(projectRoot)
1383
- )
1384
- res.end(json)
1385
- } catch (error) {
1386
- res.statusCode = 500
1387
- res.end(
1388
- error instanceof Error
1389
- ? error.message
1390
- : 'Failed to read pages.json'
1391
- )
1392
- }
1393
- return
1394
- }
1395
-
1396
- if (req.method === 'POST') {
1397
- let body = ''
1398
-
1399
- req.on('data', (chunk) => {
1400
- body += chunk
1401
- })
1402
-
1403
- req.on('end', async () => {
1404
- let previousSchema = ''
1405
- let wroteSchema = false
1406
-
1407
- try {
1408
- await fs.mkdir(path.dirname(pagesPath), { recursive: true })
1409
- previousSchema = await fs
1410
- .readFile(pagesPath, 'utf8')
1411
- .catch(() => '')
1412
-
1413
- if (previousSchema !== body) {
1414
- suppressWatchUntil = Date.now() + 1000
1415
- suppressReloadUntil = Date.now() + 5000
1416
- await fs.writeFile(pagesPath, body)
1417
- wroteSchema = true
1418
- await generateProjectFiles('pages.json write')
1419
- suppressReloadUntil = Date.now() + 1000
1420
- }
1421
-
1422
- res.statusCode = 204
1423
- res.end()
1424
- } catch (error) {
1425
- if (wroteSchema) {
1426
- suppressWatchUntil = Date.now() + 1000
1427
-
1428
- if (previousSchema) {
1429
- await fs.writeFile(pagesPath, previousSchema)
1430
- await generateProjectFiles('pages.json rollback').catch(
1431
- (rollbackError) => {
1432
- server.config.logger.error(
1433
- `[VisualBuild] Failed to restore generated files: ${
1434
- rollbackError instanceof Error
1435
- ? rollbackError.message
1436
- : String(rollbackError)
1437
- }`
1438
- )
1439
- }
1440
- )
1441
- } else {
1442
- await fs.rm(pagesPath, { force: true })
1443
- }
1444
- }
1445
-
1446
- const diagnostic = getGeneratorDiagnostic(error)
1447
- sendJson(res, 500, {
1448
- error:
1449
- diagnostic?.message ??
1450
- (error instanceof Error
1451
- ? error.message
1452
- : 'Failed to write pages.json'),
1453
- diagnostic,
1454
- })
1455
- }
1456
- })
1457
-
1458
- return
1459
- }
1460
-
1461
- res.statusCode = 405
1462
- res.end()
1463
- })
1464
- },
1465
- }
1466
- }
1467
-
1468
- export default defineConfig({
1469
- root: builderRoot,
1470
- define: {
1471
- 'process.env.BABEL_TYPES_8_BREAKING': 'false',
1472
- },
1473
- plugins: [react(), tailwindcss(), visualbuildDevApi()],
1474
- server: {
1475
- watch: {
1476
- ignored: ['**/node_modules/**', isGeneratedProjectPath],
1477
- },
1478
- },
1479
- })
1
+ import { execFile } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import fs from 'node:fs/promises'
4
+ import type { ServerResponse } from 'node:http'
5
+ import path from 'node:path'
6
+ import { promisify } from 'node:util'
7
+ import { parse } from '@babel/parser'
8
+ import { watch as watchFiles } from 'chokidar'
9
+ import { defineConfig } from 'vite'
10
+ import type { Plugin } from 'vite'
11
+ import react from '@vitejs/plugin-react'
12
+ import tailwindcss from '@tailwindcss/vite'
13
+ import { loadVisualBuildConfig } from './shared/visualbuildConfig.mjs'
14
+ import { createReactComponentSource } from './shared/createReactComponentSource.mjs'
15
+ import { resolveNpmBuildCommand } from './shared/npmBuildCommand.mjs'
16
+ import {
17
+ deployStaticOutputToVercel,
18
+ validateVercelProjectName,
19
+ validateVercelTeamId,
20
+ } from './shared/vercelDeployment.mjs'
21
+ import {
22
+ parseReactComponentPreview,
23
+ parseReactPageSource,
24
+ reconcileImportedTree,
25
+ visualTreesEqual,
26
+ type ImportedPageTree,
27
+ type ImportedVisualNode,
28
+ } from './server/parseReactPage'
29
+
30
+ const execFileAsync = promisify(execFile)
31
+ const builderPackageRoot = process.cwd()
32
+ const projectRoot = path.resolve(
33
+ process.env.VISUALBUILD_APP_DIR || builderPackageRoot
34
+ )
35
+ const builderRoot = path.resolve(builderPackageRoot, 'visual-app-builder')
36
+ const startupProjectConfig = await loadVisualBuildConfig(projectRoot)
37
+ const resolveConfiguredPath = (relativePath: string) =>
38
+ path.resolve(projectRoot, ...relativePath.split('/'))
39
+ const generatedSourceRoot = resolveConfiguredPath(
40
+ startupProjectConfig.paths.sourceDir
41
+ )
42
+ const configuredProjectRoots = [
43
+ generatedSourceRoot,
44
+ ...[
45
+ startupProjectConfig.paths.schemaFile,
46
+ startupProjectConfig.paths.componentRegistryFile,
47
+ startupProjectConfig.paths.generatedManifestFile,
48
+ ].map((filePath) => path.dirname(resolveConfiguredPath(filePath))),
49
+ ]
50
+ const maxEditableFileBytes = 2 * 1024 * 1024
51
+ const hiddenProjectEntries = new Set(['.git', 'node_modules'])
52
+
53
+ function isPathInsideDirectory(filePath: string, directoryPath: string) {
54
+ return (
55
+ filePath === directoryPath ||
56
+ filePath.startsWith(`${directoryPath.replace(/\/+$/, '')}/`)
57
+ )
58
+ }
59
+
60
+ type ProjectEntryKind = 'file' | 'directory' | 'symlink'
61
+
62
+ interface ProjectEntry {
63
+ name: string
64
+ path: string
65
+ kind: ProjectEntryKind
66
+ }
67
+
68
+ interface ManagedPage {
69
+ id: string
70
+ name: string
71
+ route: string
72
+ sourcePath?: string
73
+ root: ImportedVisualNode
74
+ components: ImportedVisualNode[]
75
+ }
76
+
77
+ interface ManagedProjectSchema {
78
+ pages: ManagedPage[]
79
+ [key: string]: unknown
80
+ }
81
+
82
+ interface ReverseSyncEvent {
83
+ type: 'reverse-sync-success' | 'reverse-sync-error'
84
+ path: string
85
+ pageId?: string
86
+ message: string
87
+ }
88
+
89
+ interface GeneratorDiagnostic {
90
+ severity: 'error'
91
+ phase: 'generate'
92
+ code: string
93
+ title: string
94
+ message: string
95
+ path: string
96
+ suggestion?: string
97
+ }
98
+
99
+ interface GeneratorDiagnosticEvent extends GeneratorDiagnostic {
100
+ type: 'generator-diagnostic'
101
+ }
102
+
103
+ interface FileSystemEvent {
104
+ type: 'filesystem-change'
105
+ path: string
106
+ action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
107
+ }
108
+
109
+ interface ManagedPagesDeletedEvent {
110
+ type: 'managed-pages-deleted'
111
+ path: string
112
+ pageIds: string[]
113
+ message: string
114
+ }
115
+
116
+ interface ManagedPagePathChange {
117
+ pageId: string
118
+ previousPath: string
119
+ sourcePath: string
120
+ }
121
+
122
+ interface ManagedPagesMovedEvent {
123
+ type: 'managed-pages-moved'
124
+ path: string
125
+ targetPath: string
126
+ pages: ManagedPagePathChange[]
127
+ message: string
128
+ }
129
+
130
+ type VisualBuildEvent =
131
+ | ReverseSyncEvent
132
+ | GeneratorDiagnosticEvent
133
+ | FileSystemEvent
134
+ | ManagedPagesDeletedEvent
135
+ | ManagedPagesMovedEvent
136
+
137
+ function isGeneratedProjectPath(watchedPath: string) {
138
+ const absolutePath = path.resolve(watchedPath)
139
+
140
+ return configuredProjectRoots.some(
141
+ (rootPath) =>
142
+ absolutePath === rootPath || absolutePath.startsWith(`${rootPath}${path.sep}`)
143
+ )
144
+ }
145
+
146
+ function sendJson(
147
+ res: { statusCode: number; setHeader: (name: string, value: string) => void; end: (body?: string) => void },
148
+ statusCode: number,
149
+ payload: unknown
150
+ ) {
151
+ res.statusCode = statusCode
152
+ res.setHeader('Content-Type', 'application/json')
153
+ res.end(JSON.stringify(payload))
154
+ }
155
+
156
+ function getProjectPath(relativePath = '') {
157
+ const normalizedInput = relativePath.replace(/\\/g, '/').trim()
158
+
159
+ if (
160
+ normalizedInput.startsWith('/') ||
161
+ /^[a-zA-Z]:/.test(normalizedInput) ||
162
+ normalizedInput.includes('\0')
163
+ ) {
164
+ throw new Error('Invalid project path')
165
+ }
166
+
167
+ const segments = normalizedInput
168
+ .split('/')
169
+ .filter((segment) => segment && segment !== '.')
170
+
171
+ if (segments.some((segment) => segment === '..')) {
172
+ throw new Error('Project path cannot leave the app root')
173
+ }
174
+
175
+ const absolutePath = path.resolve(projectRoot, ...segments)
176
+ const relativeToRoot = path.relative(projectRoot, absolutePath)
177
+
178
+ if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
179
+ throw new Error('Project path cannot leave the app root')
180
+ }
181
+
182
+ return {
183
+ absolutePath,
184
+ relativePath: segments.join('/'),
185
+ }
186
+ }
187
+
188
+ async function assertRealPathInsideProject(absolutePath: string) {
189
+ const [realProjectRoot, realTarget] = await Promise.all([
190
+ fs.realpath(projectRoot),
191
+ fs.realpath(absolutePath),
192
+ ])
193
+ const relativeToRoot = path.relative(realProjectRoot, realTarget)
194
+
195
+ if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
196
+ throw new Error('Project path cannot leave the app root')
197
+ }
198
+ }
199
+
200
+ function validateEntryName(name: unknown) {
201
+ if (
202
+ typeof name !== 'string' ||
203
+ !name.trim() ||
204
+ name === '.' ||
205
+ name === '..' ||
206
+ /[\\/:*?"<>|]/.test(name)
207
+ ) {
208
+ throw new Error('Enter a valid file or folder name')
209
+ }
210
+
211
+ return name.trim()
212
+ }
213
+
214
+ async function readJsonBody(req: NodeJS.ReadableStream) {
215
+ let body = ''
216
+
217
+ for await (const chunk of req) {
218
+ body += String(chunk)
219
+
220
+ if (body.length > maxEditableFileBytes * 2) {
221
+ throw new Error('Request is too large')
222
+ }
223
+ }
224
+
225
+ return body ? (JSON.parse(body) as Record<string, unknown>) : {}
226
+ }
227
+
228
+ async function listProjectDirectory(relativePath: string) {
229
+ const projectPath = getProjectPath(relativePath)
230
+ const stats = await fs.lstat(projectPath.absolutePath)
231
+
232
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
233
+ throw new Error('The selected path is not a directory')
234
+ }
235
+
236
+ await assertRealPathInsideProject(projectPath.absolutePath)
237
+ const directoryEntries = await fs.readdir(projectPath.absolutePath, {
238
+ withFileTypes: true,
239
+ })
240
+ const entries: ProjectEntry[] = directoryEntries
241
+ .filter((entry) => !hiddenProjectEntries.has(entry.name))
242
+ .map((entry) => {
243
+ const entryPath = projectPath.relativePath
244
+ ? `${projectPath.relativePath}/${entry.name}`
245
+ : entry.name
246
+ const kind: ProjectEntryKind = entry.isSymbolicLink()
247
+ ? 'symlink'
248
+ : entry.isDirectory()
249
+ ? 'directory'
250
+ : 'file'
251
+
252
+ return { name: entry.name, path: entryPath, kind }
253
+ })
254
+ .sort((first, second) => {
255
+ if (first.kind === 'directory' && second.kind !== 'directory') return -1
256
+ if (first.kind !== 'directory' && second.kind === 'directory') return 1
257
+ return first.name.localeCompare(second.name, undefined, {
258
+ numeric: true,
259
+ sensitivity: 'base',
260
+ })
261
+ })
262
+
263
+ return {
264
+ rootName: path.basename(projectRoot),
265
+ path: projectPath.relativePath,
266
+ entries,
267
+ }
268
+ }
269
+
270
+ async function readProjectFile(relativePath: string) {
271
+ const projectPath = getProjectPath(relativePath)
272
+ const stats = await fs.lstat(projectPath.absolutePath)
273
+
274
+ if (!stats.isFile() || stats.isSymbolicLink()) {
275
+ throw new Error('The selected path is not an editable file')
276
+ }
277
+
278
+ await assertRealPathInsideProject(projectPath.absolutePath)
279
+
280
+ if (stats.size > maxEditableFileBytes) {
281
+ return {
282
+ path: projectPath.relativePath,
283
+ content: '',
284
+ size: stats.size,
285
+ isBinary: false,
286
+ isTooLarge: true,
287
+ }
288
+ }
289
+
290
+ const buffer = await fs.readFile(projectPath.absolutePath)
291
+ const isBinary = buffer.subarray(0, 8000).includes(0)
292
+
293
+ return {
294
+ path: projectPath.relativePath,
295
+ content: isBinary ? '' : buffer.toString('utf8'),
296
+ size: stats.size,
297
+ isBinary,
298
+ isTooLarge: false,
299
+ }
300
+ }
301
+
302
+ async function importPageFromSource(
303
+ relativePath: string,
304
+ name: string,
305
+ route: string
306
+ ) {
307
+ const projectPath = getProjectPath(relativePath)
308
+ const config = await loadVisualBuildConfig(projectRoot)
309
+
310
+ if (
311
+ !isPathInsideDirectory(
312
+ projectPath.relativePath,
313
+ config.paths.sourceDir
314
+ ) ||
315
+ !/\.(jsx|tsx)$/i.test(projectPath.relativePath)
316
+ ) {
317
+ throw new Error(
318
+ `Pages must use a .jsx or .tsx file inside ${config.paths.sourceDir}`
319
+ )
320
+ }
321
+
322
+ const stats = await fs.lstat(projectPath.absolutePath)
323
+
324
+ if (!stats.isFile() || stats.isSymbolicLink()) {
325
+ throw new Error('Select a valid React source file')
326
+ }
327
+
328
+ await assertRealPathInsideProject(projectPath.absolutePath)
329
+ const source = await fs.readFile(projectPath.absolutePath, 'utf8')
330
+ const pageId = randomUUID()
331
+ const converted = parseReactPageSource(
332
+ source,
333
+ projectPath.relativePath,
334
+ (config.components ?? []).map((component) => component.name)
335
+ )
336
+
337
+ return {
338
+ id: pageId,
339
+ name,
340
+ route,
341
+ sourcePath: projectPath.relativePath,
342
+ root: {
343
+ ...converted.root,
344
+ id: `${pageId}:root`,
345
+ label: 'Page root',
346
+ children: [],
347
+ },
348
+ components: converted.components,
349
+ }
350
+ }
351
+
352
+ async function registerComponentFromSource(relativePath: string) {
353
+ const projectPath = getProjectPath(relativePath)
354
+ const currentConfig = await loadVisualBuildConfig(projectRoot)
355
+
356
+ if (
357
+ !isPathInsideDirectory(
358
+ projectPath.relativePath,
359
+ currentConfig.paths.sourceDir
360
+ ) ||
361
+ !/\.(jsx|tsx)$/i.test(projectPath.relativePath)
362
+ ) {
363
+ throw new Error(
364
+ `Components must use a .jsx or .tsx file inside ${currentConfig.paths.sourceDir}`
365
+ )
366
+ }
367
+
368
+ const stats = await fs.lstat(projectPath.absolutePath)
369
+ if (!stats.isFile() || stats.isSymbolicLink()) {
370
+ throw new Error('Select a valid React component source file')
371
+ }
372
+
373
+ await assertRealPathInsideProject(projectPath.absolutePath)
374
+ const source = await fs.readFile(projectPath.absolutePath, 'utf8')
375
+ const exportInfo = findReactComponentExport(
376
+ source,
377
+ projectPath.relativePath
378
+ )
379
+ const registrationsPath = resolveConfiguredPath(
380
+ currentConfig.paths.componentRegistryFile
381
+ )
382
+ const registrations = await fs
383
+ .readFile(registrationsPath, 'utf8')
384
+ .then((contents) => JSON.parse(contents) as { components?: unknown[] })
385
+ .catch((error: NodeJS.ErrnoException) => {
386
+ if (error.code === 'ENOENT') return { components: [] }
387
+ throw error
388
+ })
389
+ if (
390
+ (currentConfig.components ?? []).some(
391
+ (component) => component.name === exportInfo.name
392
+ )
393
+ ) {
394
+ throw new Error(`Component "${exportInfo.name}" is already registered`)
395
+ }
396
+
397
+ const registration = {
398
+ name: exportInfo.name,
399
+ importPath: projectPath.relativePath,
400
+ exportName: exportInfo.exportName,
401
+ label: splitComponentName(exportInfo.name),
402
+ category: 'Custom',
403
+ icon: exportInfo.name.charAt(0),
404
+ description: `Local ${exportInfo.name} component`,
405
+ acceptsChildren: true,
406
+ props: {
407
+ wrapperTag: {
408
+ type: 'select',
409
+ label: 'Component root tag',
410
+ default: 'div',
411
+ options: [
412
+ 'div',
413
+ 'main',
414
+ 'section',
415
+ 'article',
416
+ 'header',
417
+ 'footer',
418
+ 'nav',
419
+ 'aside',
420
+ 'form',
421
+ ],
422
+ },
423
+ id: {
424
+ type: 'string',
425
+ label: 'ID',
426
+ default: '',
427
+ },
428
+ className: {
429
+ type: 'text',
430
+ label: 'Tailwind classes',
431
+ default: '',
432
+ },
433
+ attributes: {
434
+ type: 'attributes',
435
+ label: 'Additional attributes',
436
+ default: {},
437
+ },
438
+ },
439
+ }
440
+ const nextRegistrations = {
441
+ components: [...(registrations.components ?? []), registration],
442
+ }
443
+
444
+ await fs.mkdir(path.dirname(registrationsPath), { recursive: true })
445
+ await fs.writeFile(
446
+ registrationsPath,
447
+ `${JSON.stringify(nextRegistrations, null, 2)}\n`,
448
+ 'utf8'
449
+ )
450
+ const preview = createComponentPreview(
451
+ source,
452
+ projectPath.relativePath,
453
+ [
454
+ ...(currentConfig.components ?? []).map(
455
+ (component) => component.name
456
+ ),
457
+ exportInfo.name,
458
+ ]
459
+ )
460
+ const previewDefaults = getPreviewRootDefaults(preview)
461
+ return {
462
+ ...registration,
463
+ defaultProps: {
464
+ wrapperTag: previewDefaults.wrapperTag ?? 'div',
465
+ id: previewDefaults.id ?? '',
466
+ className: previewDefaults.className ?? '',
467
+ attributes: previewDefaults.attributes ?? {},
468
+ },
469
+ propSchema: registration.props,
470
+ acceptsChildren: preview?.rendersChildren ?? true,
471
+ ...(preview ? { preview } : {}),
472
+ }
473
+ }
474
+
475
+ function createComponentPreview(
476
+ source: string,
477
+ sourcePath: string,
478
+ customComponentNames: Iterable<string>
479
+ ) {
480
+ try {
481
+ return parseReactComponentPreview(
482
+ source,
483
+ sourcePath,
484
+ customComponentNames
485
+ )
486
+ } catch {
487
+ return undefined
488
+ }
489
+ }
490
+
491
+ function getPreviewRootDefaults(
492
+ preview: ReturnType<typeof createComponentPreview>
493
+ ) {
494
+ if (!preview) return {}
495
+
496
+ return {
497
+ wrapperTag: preview.rootTag,
498
+ id: preview.rootProps.id ?? '',
499
+ className: preview.rootProps.className ?? '',
500
+ attributes: Object.fromEntries(
501
+ Object.entries(preview.rootProps).filter(
502
+ ([name]) => !['id', 'className', 'text'].includes(name)
503
+ )
504
+ ),
505
+ }
506
+ }
507
+
508
+ async function addComponentPreviews(
509
+ components: Awaited<
510
+ ReturnType<typeof loadVisualBuildConfig>
511
+ >['components']
512
+ ) {
513
+ const componentNames = components.map((component) => component.name)
514
+
515
+ return Promise.all(
516
+ components.map(async (component) => {
517
+ const source = await fs
518
+ .readFile(getProjectPath(component.importPath).absolutePath, 'utf8')
519
+ .catch(() => null)
520
+ const preview = source
521
+ ? createComponentPreview(
522
+ source,
523
+ component.importPath,
524
+ componentNames
525
+ )
526
+ : undefined
527
+ const previewDefaults = getPreviewRootDefaults(preview)
528
+
529
+ return {
530
+ ...component,
531
+ defaultProps: {
532
+ ...component.defaultProps,
533
+ ...previewDefaults,
534
+ },
535
+ acceptsChildren:
536
+ preview?.rendersChildren ?? component.acceptsChildren,
537
+ ...(preview ? { preview } : {}),
538
+ }
539
+ })
540
+ )
541
+ }
542
+
543
+ function findReactComponentExport(source: string, sourcePath: string) {
544
+ const ast = parse(source, {
545
+ sourceType: 'module',
546
+ plugins: ['jsx', 'typescript'],
547
+ })
548
+ const fallbackName = toPascalComponentName(
549
+ path.posix.basename(sourcePath).replace(/\.[^.]+$/, '')
550
+ )
551
+
552
+ for (const statement of ast.program.body) {
553
+ if (statement.type === 'ExportDefaultDeclaration') {
554
+ return {
555
+ name:
556
+ 'id' in statement.declaration && statement.declaration.id?.name
557
+ ? statement.declaration.id.name
558
+ : fallbackName,
559
+ exportName: 'default',
560
+ }
561
+ }
562
+ }
563
+
564
+ for (const statement of ast.program.body) {
565
+ if (
566
+ statement.type === 'ExportNamedDeclaration' &&
567
+ statement.declaration &&
568
+ 'id' in statement.declaration &&
569
+ statement.declaration.id?.type === 'Identifier'
570
+ ) {
571
+ return {
572
+ name: statement.declaration.id.name,
573
+ exportName: statement.declaration.id.name,
574
+ }
575
+ }
576
+ }
577
+
578
+ throw new Error(
579
+ 'The selected file must export a React component as default or as a named function/class'
580
+ )
581
+ }
582
+
583
+ function toPascalComponentName(value: string) {
584
+ const name = value
585
+ .split(/[^A-Za-z0-9]+/)
586
+ .filter(Boolean)
587
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
588
+ .join('')
589
+ return /^[A-Z]/.test(name) ? name : `Component${name}`
590
+ }
591
+
592
+ function splitComponentName(value: string) {
593
+ return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
594
+ }
595
+
596
+ function getErrorStatus(error: unknown) {
597
+ const code =
598
+ typeof error === 'object' && error !== null && 'code' in error
599
+ ? String(error.code)
600
+ : ''
601
+
602
+ if (code === 'ENOENT') return 404
603
+ if (code === 'EEXIST' || code === 'ENOTEMPTY') return 409
604
+ return 400
605
+ }
606
+
607
+ const generatorDiagnosticMarker = 'VISUALBUILD_DIAGNOSTIC:'
608
+
609
+ function getProcessOutput(error: unknown, key: 'stdout' | 'stderr') {
610
+ if (
611
+ typeof error !== 'object' ||
612
+ error === null ||
613
+ !(key in error)
614
+ ) {
615
+ return ''
616
+ }
617
+
618
+ return String(error[key] ?? '')
619
+ }
620
+
621
+ function parseGeneratorDiagnostic(
622
+ error: unknown,
623
+ reason: string
624
+ ): GeneratorDiagnostic {
625
+ const stderr = getProcessOutput(error, 'stderr')
626
+ const diagnosticLine = stderr
627
+ .split(/\r?\n/)
628
+ .find((line) => line.startsWith(generatorDiagnosticMarker))
629
+
630
+ if (diagnosticLine) {
631
+ try {
632
+ const diagnostic = JSON.parse(
633
+ diagnosticLine.slice(generatorDiagnosticMarker.length)
634
+ ) as Partial<GeneratorDiagnostic>
635
+
636
+ if (
637
+ diagnostic.severity === 'error' &&
638
+ diagnostic.phase === 'generate' &&
639
+ typeof diagnostic.code === 'string' &&
640
+ typeof diagnostic.title === 'string' &&
641
+ typeof diagnostic.message === 'string' &&
642
+ typeof diagnostic.path === 'string'
643
+ ) {
644
+ return {
645
+ severity: diagnostic.severity,
646
+ phase: diagnostic.phase,
647
+ code: diagnostic.code,
648
+ title: diagnostic.title,
649
+ message: diagnostic.message,
650
+ path: diagnostic.path,
651
+ suggestion:
652
+ typeof diagnostic.suggestion === 'string'
653
+ ? diagnostic.suggestion
654
+ : undefined,
655
+ }
656
+ }
657
+ } catch {
658
+ // Fall through to a stable diagnostic if the child process output is malformed.
659
+ }
660
+ }
661
+
662
+ return {
663
+ severity: 'error',
664
+ phase: 'generate',
665
+ code: 'GENERATION_FAILED',
666
+ title: 'React source generation failed',
667
+ message:
668
+ error instanceof Error
669
+ ? error.message
670
+ : `Failed to generate project files after ${reason}.`,
671
+ path: startupProjectConfig.paths.schemaFile,
672
+ suggestion:
673
+ 'Review the project schema and try again. The last valid files were kept.',
674
+ }
675
+ }
676
+
677
+ function getGeneratorDiagnostic(error: unknown) {
678
+ if (
679
+ typeof error !== 'object' ||
680
+ error === null ||
681
+ !('diagnostic' in error)
682
+ ) {
683
+ return null
684
+ }
685
+
686
+ return error.diagnostic as GeneratorDiagnostic
687
+ }
688
+
689
+ function visualbuildDevApi(): Plugin {
690
+ const pagesPath = resolveConfiguredPath(
691
+ startupProjectConfig.paths.schemaFile
692
+ )
693
+ const runtimeClassesPath = path.resolve(
694
+ builderRoot,
695
+ '.visualbuild-runtime-classes.html'
696
+ )
697
+ const generatorPath = path.resolve(
698
+ builderPackageRoot,
699
+ 'scripts/vb-generate.mjs'
700
+ )
701
+
702
+ return {
703
+ name: 'visualbuild-dev-api',
704
+ async configResolved() {
705
+ await fs.writeFile(
706
+ runtimeClassesPath,
707
+ await fs.readFile(runtimeClassesPath, 'utf8').catch(() => '')
708
+ )
709
+ },
710
+ configureServer(server) {
711
+ let suppressWatchUntil = 0
712
+ let suppressReloadUntil = 0
713
+ let generateTimer: ReturnType<typeof setTimeout> | null = null
714
+ let managedDeletionQueue = Promise.resolve()
715
+ let deploymentInProgress = false
716
+ const reverseSyncTimers = new Map<
717
+ string,
718
+ ReturnType<typeof setTimeout>
719
+ >()
720
+ const suppressedUnlinkPaths = new Map<string, number>()
721
+ const eventClients = new Set<ServerResponse>()
722
+ const hotChannel = server.ws as unknown as {
723
+ send: (payload: unknown, data?: unknown) => void
724
+ }
725
+ const sendHotPayload = hotChannel.send.bind(hotChannel)
726
+
727
+ hotChannel.send = (payload, data) => {
728
+ if (
729
+ Date.now() < suppressReloadUntil &&
730
+ typeof payload === 'object' &&
731
+ payload !== null &&
732
+ 'type' in payload &&
733
+ payload.type === 'full-reload'
734
+ ) {
735
+ return
736
+ }
737
+
738
+ sendHotPayload(payload, data)
739
+ }
740
+
741
+ async function generateProjectFiles(reason: string) {
742
+ try {
743
+ const { stdout, stderr } = await execFileAsync(
744
+ process.execPath,
745
+ [generatorPath],
746
+ { cwd: projectRoot }
747
+ )
748
+
749
+ if (stdout.trim()) {
750
+ server.config.logger.info(stdout.trim())
751
+ }
752
+
753
+ if (stderr.trim()) {
754
+ server.config.logger.warn(stderr.trim())
755
+ }
756
+ } catch (error) {
757
+ const diagnostic = parseGeneratorDiagnostic(error, reason)
758
+ server.config.logger.error(
759
+ `[VisualBuild] ${diagnostic.title}: ${diagnostic.message}`
760
+ )
761
+ broadcastVisualBuildEvent({
762
+ type: 'generator-diagnostic',
763
+ ...diagnostic,
764
+ })
765
+
766
+ const generationError = new Error(diagnostic.message)
767
+ Object.assign(generationError, { diagnostic })
768
+ throw generationError
769
+ }
770
+ }
771
+
772
+ const pagesWatcher = watchFiles(pagesPath, {
773
+ ignoreInitial: true,
774
+ usePolling: process.platform === 'win32',
775
+ interval: 100,
776
+ awaitWriteFinish: {
777
+ stabilityThreshold: 120,
778
+ pollInterval: 25,
779
+ },
780
+ }).on('change', () => {
781
+ if (Date.now() < suppressWatchUntil) {
782
+ return
783
+ }
784
+
785
+ if (generateTimer) {
786
+ clearTimeout(generateTimer)
787
+ }
788
+
789
+ generateTimer = setTimeout(() => {
790
+ generateTimer = null
791
+ void generateProjectFiles('pages.json change').catch(() => {})
792
+ }, 80)
793
+ })
794
+
795
+ function broadcastVisualBuildEvent(event: VisualBuildEvent) {
796
+ const payload = `event: visualbuild\ndata: ${JSON.stringify(event)}\n\n`
797
+
798
+ for (const client of eventClients) {
799
+ client.write(payload)
800
+ }
801
+ }
802
+
803
+ function normalizeManagedPath(value: unknown) {
804
+ return typeof value === 'string'
805
+ ? value.replace(/\\/g, '/').replace(/^\.\/+/, '')
806
+ : ''
807
+ }
808
+
809
+ function findManagedPagesForPath(
810
+ schema: ManagedProjectSchema,
811
+ relativePath: string,
812
+ isDirectory: boolean
813
+ ) {
814
+ const normalizedTarget = normalizeManagedPath(relativePath)
815
+
816
+ return schema.pages.filter((page) => {
817
+ const sourcePath = normalizeManagedPath(page.sourcePath)
818
+
819
+ return (
820
+ sourcePath === normalizedTarget ||
821
+ (isDirectory && sourcePath.startsWith(`${normalizedTarget}/`))
822
+ )
823
+ })
824
+ }
825
+
826
+ function replaceManagedPathPrefix(
827
+ sourcePath: string,
828
+ previousPath: string,
829
+ targetPath: string
830
+ ) {
831
+ return `${targetPath}${sourcePath.slice(previousPath.length)}`
832
+ }
833
+
834
+ function validateManagedPagePaths(schema: ManagedProjectSchema) {
835
+ const seenSourcePaths = new Set<string>()
836
+
837
+ for (const page of schema.pages) {
838
+ const sourcePath = normalizeManagedPath(page.sourcePath)
839
+
840
+ if (
841
+ !isPathInsideDirectory(
842
+ sourcePath,
843
+ startupProjectConfig.paths.sourceDir
844
+ ) ||
845
+ !/\.(jsx|tsx)$/i.test(sourcePath) ||
846
+ sourcePath.includes('/../') ||
847
+ sourcePath.endsWith('/..')
848
+ ) {
849
+ throw new Error(
850
+ `Managed page "${page.name}" must remain a .jsx or .tsx file inside ${startupProjectConfig.paths.sourceDir}`
851
+ )
852
+ }
853
+
854
+ const layoutPath =
855
+ `${startupProjectConfig.paths.layoutsDir}/AppLayout.tsx`
856
+ if (
857
+ sourcePath === startupProjectConfig.paths.appFile ||
858
+ sourcePath === layoutPath
859
+ ) {
860
+ throw new Error(`Managed page path is reserved: ${sourcePath}`)
861
+ }
862
+
863
+ if (seenSourcePaths.has(sourcePath)) {
864
+ throw new Error(`Duplicate managed page path: ${sourcePath}`)
865
+ }
866
+
867
+ seenSourcePaths.add(sourcePath)
868
+ }
869
+ }
870
+
871
+ function suppressManagedUnlink(relativePath: string) {
872
+ suppressedUnlinkPaths.set(
873
+ normalizeManagedPath(relativePath),
874
+ Date.now() + 2500
875
+ )
876
+ }
877
+
878
+ function consumeSuppressedUnlink(relativePath: string) {
879
+ const normalizedPath = normalizeManagedPath(relativePath)
880
+ const expiresAt = suppressedUnlinkPaths.get(normalizedPath)
881
+
882
+ if (!expiresAt) return false
883
+
884
+ suppressedUnlinkPaths.delete(normalizedPath)
885
+ return expiresAt >= Date.now()
886
+ }
887
+
888
+ async function persistManagedPageDeletion(
889
+ relativePath: string,
890
+ isDirectory: boolean,
891
+ deleteTarget?: () => Promise<void>
892
+ ) {
893
+ const schema = JSON.parse(
894
+ await fs.readFile(pagesPath, 'utf8')
895
+ ) as ManagedProjectSchema
896
+ const removedPages = findManagedPagesForPath(
897
+ schema,
898
+ relativePath,
899
+ isDirectory
900
+ )
901
+
902
+ if (removedPages.length === 0) {
903
+ if (deleteTarget) await deleteTarget()
904
+ return []
905
+ }
906
+
907
+ if (removedPages.length === schema.pages.length) {
908
+ throw new Error(
909
+ 'A VisualBuild project must keep at least one registered page'
910
+ )
911
+ }
912
+
913
+ const removedPageIds = new Set(removedPages.map((page) => page.id))
914
+ const nextSchema: ManagedProjectSchema = {
915
+ ...schema,
916
+ pages: schema.pages.filter((page) => !removedPageIds.has(page.id)),
917
+ }
918
+
919
+ for (const page of removedPages) {
920
+ const sourcePath = normalizeManagedPath(page.sourcePath)
921
+
922
+ if (sourcePath) {
923
+ suppressManagedUnlink(sourcePath)
924
+ const pendingTimer = reverseSyncTimers.get(
925
+ path.resolve(projectRoot, sourcePath)
926
+ )
927
+
928
+ if (pendingTimer) {
929
+ clearTimeout(pendingTimer)
930
+ reverseSyncTimers.delete(path.resolve(projectRoot, sourcePath))
931
+ }
932
+ }
933
+ }
934
+
935
+ suppressWatchUntil = Date.now() + 1500
936
+ await fs.writeFile(
937
+ pagesPath,
938
+ `${JSON.stringify(nextSchema, null, 2)}\n`,
939
+ 'utf8'
940
+ )
941
+
942
+ try {
943
+ if (deleteTarget) await deleteTarget()
944
+ } catch (error) {
945
+ suppressWatchUntil = Date.now() + 1500
946
+ await fs.writeFile(
947
+ pagesPath,
948
+ `${JSON.stringify(schema, null, 2)}\n`,
949
+ 'utf8'
950
+ )
951
+ throw error
952
+ }
953
+
954
+ await generateProjectFiles('managed page deletion')
955
+ broadcastVisualBuildEvent({
956
+ type: 'managed-pages-deleted',
957
+ path: normalizeManagedPath(relativePath),
958
+ pageIds: removedPages.map((page) => page.id),
959
+ message:
960
+ removedPages.length === 1
961
+ ? `Deleted ${removedPages[0].name}`
962
+ : `Deleted ${removedPages.length} pages`,
963
+ })
964
+
965
+ return removedPages
966
+ }
967
+
968
+ async function persistManagedPageMove(
969
+ relativePath: string,
970
+ targetRelativePath: string,
971
+ isDirectory: boolean,
972
+ moveTarget: () => Promise<void>,
973
+ rollbackTarget: () => Promise<void>
974
+ ) {
975
+ const normalizedSource = normalizeManagedPath(relativePath)
976
+ const normalizedTarget = normalizeManagedPath(targetRelativePath)
977
+ const schema = JSON.parse(
978
+ await fs.readFile(pagesPath, 'utf8')
979
+ ) as ManagedProjectSchema
980
+ const affectedPages = findManagedPagesForPath(
981
+ schema,
982
+ normalizedSource,
983
+ isDirectory
984
+ )
985
+
986
+ if (affectedPages.length === 0) {
987
+ await moveTarget()
988
+ return []
989
+ }
990
+
991
+ const affectedPageIds = new Set(affectedPages.map((page) => page.id))
992
+ const pathChanges: ManagedPagePathChange[] = affectedPages.map(
993
+ (page) => {
994
+ const previousPath = normalizeManagedPath(page.sourcePath)
995
+
996
+ return {
997
+ pageId: page.id,
998
+ previousPath,
999
+ sourcePath: isDirectory
1000
+ ? replaceManagedPathPrefix(
1001
+ previousPath,
1002
+ normalizedSource,
1003
+ normalizedTarget
1004
+ )
1005
+ : normalizedTarget,
1006
+ }
1007
+ }
1008
+ )
1009
+ const nextPaths = new Map(
1010
+ pathChanges.map((change) => [change.pageId, change.sourcePath])
1011
+ )
1012
+ const nextSchema: ManagedProjectSchema = {
1013
+ ...schema,
1014
+ pages: schema.pages.map((page) =>
1015
+ affectedPageIds.has(page.id)
1016
+ ? { ...page, sourcePath: nextPaths.get(page.id) }
1017
+ : page
1018
+ ),
1019
+ }
1020
+
1021
+ validateManagedPagePaths(nextSchema)
1022
+
1023
+ for (const change of pathChanges) {
1024
+ suppressManagedUnlink(change.previousPath)
1025
+ const previousAbsolutePath = path.resolve(
1026
+ projectRoot,
1027
+ change.previousPath
1028
+ )
1029
+ const pendingTimer = reverseSyncTimers.get(previousAbsolutePath)
1030
+
1031
+ if (pendingTimer) {
1032
+ clearTimeout(pendingTimer)
1033
+ reverseSyncTimers.delete(previousAbsolutePath)
1034
+ }
1035
+ }
1036
+
1037
+ let targetMoved = false
1038
+ let schemaUpdated = false
1039
+
1040
+ try {
1041
+ await moveTarget()
1042
+ targetMoved = true
1043
+ suppressWatchUntil = Date.now() + 1500
1044
+ await fs.writeFile(
1045
+ pagesPath,
1046
+ `${JSON.stringify(nextSchema, null, 2)}\n`,
1047
+ 'utf8'
1048
+ )
1049
+ schemaUpdated = true
1050
+ await generateProjectFiles('managed page move')
1051
+ } catch (error) {
1052
+ suppressWatchUntil = Date.now() + 1500
1053
+
1054
+ if (schemaUpdated) {
1055
+ await fs.writeFile(
1056
+ pagesPath,
1057
+ `${JSON.stringify(schema, null, 2)}\n`,
1058
+ 'utf8'
1059
+ )
1060
+ }
1061
+
1062
+ if (targetMoved) {
1063
+ await rollbackTarget()
1064
+ }
1065
+
1066
+ if (schemaUpdated) {
1067
+ await generateProjectFiles('managed page move rollback').catch(
1068
+ () => {}
1069
+ )
1070
+ }
1071
+
1072
+ throw error
1073
+ }
1074
+
1075
+ broadcastVisualBuildEvent({
1076
+ type: 'managed-pages-moved',
1077
+ path: normalizedSource,
1078
+ targetPath: normalizedTarget,
1079
+ pages: pathChanges,
1080
+ message:
1081
+ pathChanges.length === 1
1082
+ ? `Moved ${affectedPages[0].name}`
1083
+ : `Moved ${pathChanges.length} managed pages`,
1084
+ })
1085
+
1086
+ return pathChanges
1087
+ }
1088
+
1089
+ function unregisterExternallyDeletedPage(relativePath: string) {
1090
+ managedDeletionQueue = managedDeletionQueue
1091
+ .then(async () => {
1092
+ const schema = JSON.parse(
1093
+ await fs.readFile(pagesPath, 'utf8')
1094
+ ) as ManagedProjectSchema
1095
+ const removedPages = findManagedPagesForPath(
1096
+ schema,
1097
+ relativePath,
1098
+ false
1099
+ )
1100
+
1101
+ if (removedPages.length === 0) return
1102
+
1103
+ if (removedPages.length === schema.pages.length) {
1104
+ await generateProjectFiles('restore required page')
1105
+ broadcastVisualBuildEvent({
1106
+ type: 'reverse-sync-error',
1107
+ path: relativePath,
1108
+ pageId: removedPages[0].id,
1109
+ message:
1110
+ 'The only registered page cannot be deleted. VisualBuild restored its source file.',
1111
+ })
1112
+ return
1113
+ }
1114
+
1115
+ await persistManagedPageDeletion(relativePath, false)
1116
+ })
1117
+ .catch((error) => {
1118
+ const message =
1119
+ error instanceof Error
1120
+ ? error.message
1121
+ : 'Could not unregister the deleted page.'
1122
+ broadcastVisualBuildEvent({
1123
+ type: 'reverse-sync-error',
1124
+ path: relativePath,
1125
+ message,
1126
+ })
1127
+ server.config.logger.warn(
1128
+ `[VisualBuild] Page deletion sync skipped ${relativePath}: ${message}`
1129
+ )
1130
+ })
1131
+ }
1132
+
1133
+ async function reverseSyncSourceFile(absolutePath: string) {
1134
+ const relativePath = path
1135
+ .relative(projectRoot, absolutePath)
1136
+ .split(path.sep)
1137
+ .join('/')
1138
+
1139
+ try {
1140
+ const schema = JSON.parse(
1141
+ await fs.readFile(pagesPath, 'utf8')
1142
+ ) as ManagedProjectSchema
1143
+ const pageIndex = schema.pages.findIndex(
1144
+ (page) =>
1145
+ normalizeManagedPath(page.sourcePath) === relativePath
1146
+ )
1147
+
1148
+ if (pageIndex < 0) return
1149
+
1150
+ const page = schema.pages[pageIndex]
1151
+ const source = await fs.readFile(absolutePath, 'utf8')
1152
+ const config = await loadVisualBuildConfig(projectRoot)
1153
+ const customComponentNames = (config.components ?? []).map(
1154
+ (component) => component.name
1155
+ )
1156
+ const imported = parseReactPageSource(
1157
+ source,
1158
+ relativePath,
1159
+ customComponentNames
1160
+ )
1161
+ const current: ImportedPageTree = {
1162
+ root: page.root,
1163
+ components: page.components,
1164
+ }
1165
+ const reconciled = reconcileImportedTree(
1166
+ imported,
1167
+ current,
1168
+ customComponentNames
1169
+ )
1170
+
1171
+ if (visualTreesEqual(reconciled, current)) return
1172
+
1173
+ schema.pages[pageIndex] = {
1174
+ ...page,
1175
+ root: reconciled.root,
1176
+ components: reconciled.components,
1177
+ }
1178
+ suppressWatchUntil = Date.now() + 1200
1179
+ await fs.writeFile(
1180
+ pagesPath,
1181
+ `${JSON.stringify(schema, null, 2)}\n`,
1182
+ 'utf8'
1183
+ )
1184
+ broadcastVisualBuildEvent({
1185
+ type: 'reverse-sync-success',
1186
+ path: relativePath,
1187
+ pageId: page.id,
1188
+ message: `Updated ${page.name} from ${relativePath}`,
1189
+ })
1190
+ server.config.logger.info(
1191
+ `[VisualBuild] Reverse synced ${relativePath}`
1192
+ )
1193
+ } catch (error) {
1194
+ const message =
1195
+ error instanceof Error
1196
+ ? error.message
1197
+ : 'Reverse sync could not parse the managed page.'
1198
+ broadcastVisualBuildEvent({
1199
+ type: 'reverse-sync-error',
1200
+ path: relativePath,
1201
+ message,
1202
+ })
1203
+ server.config.logger.warn(
1204
+ `[VisualBuild] Reverse sync skipped ${relativePath}: ${message}`
1205
+ )
1206
+ }
1207
+ }
1208
+
1209
+ function scheduleReverseSync(absolutePath: string) {
1210
+ if (!/\.(jsx|tsx)$/i.test(absolutePath)) return
1211
+
1212
+ const previousTimer = reverseSyncTimers.get(absolutePath)
1213
+ if (previousTimer) clearTimeout(previousTimer)
1214
+
1215
+ reverseSyncTimers.set(
1216
+ absolutePath,
1217
+ setTimeout(() => {
1218
+ reverseSyncTimers.delete(absolutePath)
1219
+ void reverseSyncSourceFile(absolutePath)
1220
+ }, 100)
1221
+ )
1222
+ }
1223
+
1224
+ function handleProjectFileChange(
1225
+ action: FileSystemEvent['action'],
1226
+ absolutePath: string
1227
+ ) {
1228
+ const relativePath = path
1229
+ .relative(projectRoot, absolutePath)
1230
+ .split(path.sep)
1231
+ .join('/')
1232
+
1233
+ if (
1234
+ !relativePath ||
1235
+ relativePath === '..' ||
1236
+ relativePath.startsWith('../')
1237
+ ) {
1238
+ return
1239
+ }
1240
+
1241
+ broadcastVisualBuildEvent({
1242
+ type: 'filesystem-change',
1243
+ path: relativePath,
1244
+ action,
1245
+ })
1246
+
1247
+ const relativeToSource = path.relative(generatedSourceRoot, absolutePath)
1248
+ const isInsideSource =
1249
+ relativeToSource &&
1250
+ relativeToSource !== '..' &&
1251
+ !relativeToSource.startsWith(`..${path.sep}`) &&
1252
+ !path.isAbsolute(relativeToSource)
1253
+
1254
+ if (isInsideSource && (action === 'add' || action === 'change')) {
1255
+ scheduleReverseSync(absolutePath)
1256
+ }
1257
+
1258
+ if (
1259
+ isInsideSource &&
1260
+ action === 'unlink' &&
1261
+ !consumeSuppressedUnlink(relativePath)
1262
+ ) {
1263
+ unregisterExternallyDeletedPage(relativePath)
1264
+ }
1265
+ }
1266
+
1267
+ const projectWatcher = watchFiles(projectRoot, {
1268
+ ignoreInitial: true,
1269
+ usePolling: process.platform === 'win32',
1270
+ interval: 100,
1271
+ ignored: (watchedPath) => {
1272
+ const relativePath = path.relative(projectRoot, path.resolve(watchedPath))
1273
+ const segments = relativePath.split(path.sep)
1274
+
1275
+ return segments.some((segment) =>
1276
+ ['.git', 'node_modules', 'dist', '.visualbuild-qa'].includes(segment)
1277
+ )
1278
+ },
1279
+ awaitWriteFinish: {
1280
+ stabilityThreshold: 140,
1281
+ pollInterval: 25,
1282
+ },
1283
+ })
1284
+ .on('add', (changedPath) => handleProjectFileChange('add', changedPath))
1285
+ .on('change', (changedPath) =>
1286
+ handleProjectFileChange('change', changedPath)
1287
+ )
1288
+ .on('unlink', (changedPath) =>
1289
+ handleProjectFileChange('unlink', changedPath)
1290
+ )
1291
+ .on('addDir', (changedPath) =>
1292
+ handleProjectFileChange('addDir', changedPath)
1293
+ )
1294
+ .on('unlinkDir', (changedPath) =>
1295
+ handleProjectFileChange('unlinkDir', changedPath)
1296
+ )
1297
+ .on('ready', () => {
1298
+ server.config.logger.info(
1299
+ `[VisualBuild] Watching ${projectRoot} for project and managed source changes`
1300
+ )
1301
+ })
1302
+ .on('error', (error) => {
1303
+ server.config.logger.warn(
1304
+ `[VisualBuild] Source watcher error: ${error.message}`
1305
+ )
1306
+ })
1307
+
1308
+ const keepAliveTimer = setInterval(() => {
1309
+ for (const client of eventClients) {
1310
+ client.write(': keep-alive\n\n')
1311
+ }
1312
+ }, 15000)
1313
+
1314
+ server.httpServer?.once('close', () => {
1315
+ void pagesWatcher.close()
1316
+ void projectWatcher.close()
1317
+ clearInterval(keepAliveTimer)
1318
+
1319
+ for (const client of eventClients) {
1320
+ client.end()
1321
+ }
1322
+
1323
+ for (const timer of reverseSyncTimers.values()) {
1324
+ clearTimeout(timer)
1325
+ }
1326
+
1327
+ if (generateTimer) {
1328
+ clearTimeout(generateTimer)
1329
+ }
1330
+ })
1331
+
1332
+ void generateProjectFiles('dev server start').catch(() => {})
1333
+
1334
+ server.middlewares.use('/__visualbuild/events', (req, res) => {
1335
+ if (req.method !== 'GET') {
1336
+ res.statusCode = 405
1337
+ res.end()
1338
+ return
1339
+ }
1340
+
1341
+ res.statusCode = 200
1342
+ res.setHeader('Content-Type', 'text/event-stream')
1343
+ res.setHeader('Cache-Control', 'no-cache, no-transform')
1344
+ res.setHeader('Connection', 'keep-alive')
1345
+ res.write('retry: 1500\n\n')
1346
+ eventClients.add(res)
1347
+
1348
+ req.once('close', () => {
1349
+ eventClients.delete(res)
1350
+ })
1351
+ })
1352
+
1353
+ server.middlewares.use('/__visualbuild/config', async (req, res) => {
1354
+ if (req.method !== 'GET') {
1355
+ res.statusCode = 405
1356
+ res.end()
1357
+ return
1358
+ }
1359
+
1360
+ try {
1361
+ const config = await loadVisualBuildConfig(projectRoot)
1362
+ sendJson(res, 200, {
1363
+ paths: config.paths,
1364
+ editor: config.editor,
1365
+ generator: config.generator,
1366
+ deployment: config.deployment,
1367
+ customComponents: await addComponentPreviews(
1368
+ config.components ?? []
1369
+ ),
1370
+ })
1371
+ } catch (error) {
1372
+ sendJson(res, 400, {
1373
+ error:
1374
+ error instanceof Error
1375
+ ? error.message
1376
+ : 'Failed to load visualbuild.config.ts',
1377
+ })
1378
+ }
1379
+ })
1380
+
1381
+ server.middlewares.use('/__visualbuild/deploy', async (req, res) => {
1382
+ try {
1383
+ const config = await loadVisualBuildConfig(projectRoot)
1384
+ const packageJson = JSON.parse(
1385
+ await fs.readFile(path.join(projectRoot, 'package.json'), 'utf8')
1386
+ ) as { name?: unknown }
1387
+ const defaultProjectName = validateVercelProjectName(
1388
+ config.deployment.projectName ?? packageJson.name ?? path.basename(projectRoot)
1389
+ )
1390
+ const configuredTeamId = validateVercelTeamId(
1391
+ config.deployment.teamId ?? process.env.VERCEL_TEAM_ID
1392
+ )
1393
+ const token =
1394
+ process.env.VERCEL_TOKEN ?? process.env.VERCEL_ACCESS_TOKEN ?? ''
1395
+
1396
+ if (req.method === 'GET') {
1397
+ sendJson(res, 200, {
1398
+ provider: 'vercel',
1399
+ tokenConfigured: Boolean(token.trim()),
1400
+ projectName: defaultProjectName,
1401
+ teamId: configuredTeamId ?? '',
1402
+ outputDirectory: config.deployment.outputDirectory,
1403
+ sourcePolicy: 'static-build-output-only',
1404
+ })
1405
+ return
1406
+ }
1407
+
1408
+ if (req.method !== 'POST') {
1409
+ res.statusCode = 405
1410
+ res.end()
1411
+ return
1412
+ }
1413
+
1414
+ if (!token.trim()) {
1415
+ sendJson(res, 401, {
1416
+ error:
1417
+ 'Set VERCEL_TOKEN in the visual-builder terminal, restart the builder, and try again.',
1418
+ })
1419
+ return
1420
+ }
1421
+
1422
+ if (deploymentInProgress) {
1423
+ sendJson(res, 409, {
1424
+ error: 'A Vercel deployment is already in progress',
1425
+ })
1426
+ return
1427
+ }
1428
+
1429
+ const payload = await readJsonBody(req)
1430
+ const projectName = validateVercelProjectName(
1431
+ typeof payload.projectName === 'string'
1432
+ ? payload.projectName
1433
+ : defaultProjectName
1434
+ )
1435
+ const teamId = validateVercelTeamId(
1436
+ typeof payload.teamId === 'string'
1437
+ ? payload.teamId
1438
+ : configuredTeamId
1439
+ )
1440
+ const outputPath = getProjectPath(
1441
+ config.deployment.outputDirectory
1442
+ )
1443
+
1444
+ deploymentInProgress = true
1445
+
1446
+ try {
1447
+ await generateProjectFiles('Vercel deployment')
1448
+ const npmBuildCommand = resolveNpmBuildCommand()
1449
+ const { stdout, stderr } = await execFileAsync(
1450
+ npmBuildCommand.executable,
1451
+ npmBuildCommand.args,
1452
+ {
1453
+ cwd: projectRoot,
1454
+ env: {
1455
+ ...process.env,
1456
+ VISUALBUILD_DEPLOYMENT: 'vercel',
1457
+ },
1458
+ maxBuffer: 10 * 1024 * 1024,
1459
+ }
1460
+ )
1461
+
1462
+ if (stdout.trim()) server.config.logger.info(stdout.trim())
1463
+ if (stderr.trim()) server.config.logger.warn(stderr.trim())
1464
+
1465
+ const result = await deployStaticOutputToVercel({
1466
+ outputRoot: outputPath.absolutePath,
1467
+ token,
1468
+ projectName,
1469
+ teamId,
1470
+ })
1471
+
1472
+ sendJson(res, 200, result)
1473
+ } finally {
1474
+ deploymentInProgress = false
1475
+ }
1476
+ } catch (error) {
1477
+ const message =
1478
+ error instanceof Error ? error.message : 'Vercel deployment failed'
1479
+ server.config.logger.error(`[VisualBuild] Deployment failed: ${message}`)
1480
+ sendJson(res, 400, { error: message })
1481
+ }
1482
+ })
1483
+
1484
+ server.middlewares.use('/__visualbuild/files', async (req, res) => {
1485
+ const requestUrl = new URL(req.url ?? '/', 'http://visualbuild.local')
1486
+
1487
+ try {
1488
+ if (req.method === 'GET') {
1489
+ const result = await listProjectDirectory(
1490
+ requestUrl.searchParams.get('path') ?? ''
1491
+ )
1492
+ sendJson(res, 200, result)
1493
+ return
1494
+ }
1495
+
1496
+ if (req.method === 'POST') {
1497
+ const payload = await readJsonBody(req)
1498
+ const parentPath = getProjectPath(
1499
+ typeof payload.parentPath === 'string' ? payload.parentPath : ''
1500
+ )
1501
+ const parentStats = await fs.lstat(parentPath.absolutePath)
1502
+
1503
+ if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) {
1504
+ throw new Error('Select a valid parent folder')
1505
+ }
1506
+
1507
+ await assertRealPathInsideProject(parentPath.absolutePath)
1508
+ const name = validateEntryName(payload.name)
1509
+ const targetPath = getProjectPath(
1510
+ parentPath.relativePath
1511
+ ? `${parentPath.relativePath}/${name}`
1512
+ : name
1513
+ )
1514
+
1515
+ if (payload.kind === 'directory') {
1516
+ await fs.mkdir(targetPath.absolutePath)
1517
+ } else if (payload.kind === 'file') {
1518
+ await fs.writeFile(
1519
+ targetPath.absolutePath,
1520
+ createReactComponentSource(name),
1521
+ {
1522
+ encoding: 'utf8',
1523
+ flag: 'wx',
1524
+ }
1525
+ )
1526
+ } else {
1527
+ throw new Error('Entry type must be file or directory')
1528
+ }
1529
+
1530
+ sendJson(res, 201, {
1531
+ name,
1532
+ path: targetPath.relativePath,
1533
+ kind: payload.kind,
1534
+ })
1535
+ return
1536
+ }
1537
+
1538
+ if (req.method === 'PATCH') {
1539
+ const payload = await readJsonBody(req)
1540
+ const sourcePath = getProjectPath(
1541
+ typeof payload.path === 'string' ? payload.path : ''
1542
+ )
1543
+
1544
+ if (!sourcePath.relativePath) {
1545
+ throw new Error('The project root cannot be renamed')
1546
+ }
1547
+
1548
+ const sourceStats = await fs.lstat(sourcePath.absolutePath)
1549
+
1550
+ if (!sourceStats.isSymbolicLink()) {
1551
+ await assertRealPathInsideProject(sourcePath.absolutePath)
1552
+ }
1553
+
1554
+ const name = validateEntryName(payload.name)
1555
+ const currentParentPath = path.posix.dirname(
1556
+ sourcePath.relativePath
1557
+ )
1558
+ const requestedParentPath =
1559
+ typeof payload.parentPath === 'string'
1560
+ ? getProjectPath(payload.parentPath)
1561
+ : getProjectPath(
1562
+ currentParentPath === '.' ? '' : currentParentPath
1563
+ )
1564
+ const parentStats = await fs.lstat(
1565
+ requestedParentPath.absolutePath
1566
+ )
1567
+
1568
+ if (
1569
+ !parentStats.isDirectory() ||
1570
+ parentStats.isSymbolicLink()
1571
+ ) {
1572
+ throw new Error('Select a valid destination folder')
1573
+ }
1574
+
1575
+ await assertRealPathInsideProject(
1576
+ requestedParentPath.absolutePath
1577
+ )
1578
+ const targetPath = getProjectPath(
1579
+ requestedParentPath.relativePath
1580
+ ? `${requestedParentPath.relativePath}/${name}`
1581
+ : name
1582
+ )
1583
+
1584
+ if (targetPath.relativePath === sourcePath.relativePath) {
1585
+ sendJson(res, 200, {
1586
+ name,
1587
+ path: targetPath.relativePath,
1588
+ kind: sourceStats.isSymbolicLink()
1589
+ ? 'symlink'
1590
+ : sourceStats.isDirectory()
1591
+ ? 'directory'
1592
+ : 'file',
1593
+ managedPages: [],
1594
+ })
1595
+ return
1596
+ }
1597
+
1598
+ if (
1599
+ sourceStats.isDirectory() &&
1600
+ targetPath.relativePath.startsWith(
1601
+ `${sourcePath.relativePath}/`
1602
+ )
1603
+ ) {
1604
+ throw new Error('A folder cannot be moved inside itself')
1605
+ }
1606
+
1607
+ const targetExists = await fs
1608
+ .lstat(targetPath.absolutePath)
1609
+ .then(() => true)
1610
+ .catch((error: NodeJS.ErrnoException) => {
1611
+ if (error.code === 'ENOENT') return false
1612
+ throw error
1613
+ })
1614
+
1615
+ if (targetExists) {
1616
+ throw new Error('A file or folder with that name already exists')
1617
+ }
1618
+
1619
+ const managedPages = await persistManagedPageMove(
1620
+ sourcePath.relativePath,
1621
+ targetPath.relativePath,
1622
+ sourceStats.isDirectory() && !sourceStats.isSymbolicLink(),
1623
+ () =>
1624
+ fs.rename(sourcePath.absolutePath, targetPath.absolutePath),
1625
+ () =>
1626
+ fs.rename(targetPath.absolutePath, sourcePath.absolutePath)
1627
+ )
1628
+ sendJson(res, 200, {
1629
+ name,
1630
+ path: targetPath.relativePath,
1631
+ kind: sourceStats.isSymbolicLink()
1632
+ ? 'symlink'
1633
+ : sourceStats.isDirectory()
1634
+ ? 'directory'
1635
+ : 'file',
1636
+ managedPages,
1637
+ })
1638
+ return
1639
+ }
1640
+
1641
+ if (req.method === 'DELETE') {
1642
+ const targetPath = getProjectPath(
1643
+ requestUrl.searchParams.get('path') ?? ''
1644
+ )
1645
+
1646
+ if (!targetPath.relativePath) {
1647
+ throw new Error('The project root cannot be deleted')
1648
+ }
1649
+
1650
+ const targetStats = await fs.lstat(targetPath.absolutePath)
1651
+
1652
+ if (!targetStats.isSymbolicLink()) {
1653
+ await assertRealPathInsideProject(targetPath.absolutePath)
1654
+ }
1655
+
1656
+ const deleteTarget = () =>
1657
+ fs.rm(targetPath.absolutePath, {
1658
+ recursive: targetStats.isDirectory(),
1659
+ })
1660
+ const removedPages = await persistManagedPageDeletion(
1661
+ targetPath.relativePath,
1662
+ targetStats.isDirectory(),
1663
+ deleteTarget
1664
+ )
1665
+
1666
+ sendJson(res, 200, {
1667
+ path: targetPath.relativePath,
1668
+ removedPageIds: removedPages.map((page) => page.id),
1669
+ })
1670
+ return
1671
+ }
1672
+
1673
+ res.statusCode = 405
1674
+ res.end()
1675
+ } catch (error) {
1676
+ sendJson(res, getErrorStatus(error), {
1677
+ error:
1678
+ error instanceof Error
1679
+ ? error.message
1680
+ : 'Project file operation failed',
1681
+ })
1682
+ }
1683
+ })
1684
+
1685
+ server.middlewares.use('/__visualbuild/file', async (req, res) => {
1686
+ const requestUrl = new URL(req.url ?? '/', 'http://visualbuild.local')
1687
+
1688
+ try {
1689
+ if (req.method === 'GET') {
1690
+ sendJson(
1691
+ res,
1692
+ 200,
1693
+ await readProjectFile(requestUrl.searchParams.get('path') ?? '')
1694
+ )
1695
+ return
1696
+ }
1697
+
1698
+ if (req.method === 'PUT') {
1699
+ const payload = await readJsonBody(req)
1700
+ const targetPath = getProjectPath(
1701
+ typeof payload.path === 'string' ? payload.path : ''
1702
+ )
1703
+ const stats = await fs.lstat(targetPath.absolutePath)
1704
+
1705
+ if (!stats.isFile() || stats.isSymbolicLink()) {
1706
+ throw new Error('The selected path is not an editable file')
1707
+ }
1708
+
1709
+ await assertRealPathInsideProject(targetPath.absolutePath)
1710
+
1711
+ if (typeof payload.content !== 'string') {
1712
+ throw new Error('File content must be text')
1713
+ }
1714
+
1715
+ if (Buffer.byteLength(payload.content, 'utf8') > maxEditableFileBytes) {
1716
+ throw new Error('File content exceeds the 2 MB editor limit')
1717
+ }
1718
+
1719
+ await fs.writeFile(targetPath.absolutePath, payload.content, 'utf8')
1720
+ res.statusCode = 204
1721
+ res.end()
1722
+ return
1723
+ }
1724
+
1725
+ res.statusCode = 405
1726
+ res.end()
1727
+ } catch (error) {
1728
+ sendJson(res, getErrorStatus(error), {
1729
+ error:
1730
+ error instanceof Error
1731
+ ? error.message
1732
+ : 'Project file operation failed',
1733
+ })
1734
+ }
1735
+ })
1736
+
1737
+ server.middlewares.use('/__visualbuild/import-page', async (req, res) => {
1738
+ try {
1739
+ if (req.method !== 'POST') {
1740
+ res.statusCode = 405
1741
+ res.end()
1742
+ return
1743
+ }
1744
+
1745
+ const payload = await readJsonBody(req)
1746
+ const name =
1747
+ typeof payload.name === 'string' ? payload.name.trim() : ''
1748
+ const route =
1749
+ typeof payload.route === 'string' ? payload.route.trim() : ''
1750
+ const sourcePath =
1751
+ typeof payload.path === 'string' ? payload.path : ''
1752
+
1753
+ if (!name) {
1754
+ throw new Error('Page name is required')
1755
+ }
1756
+
1757
+ if (!route.startsWith('/')) {
1758
+ throw new Error('Page route must start with "/"')
1759
+ }
1760
+
1761
+ sendJson(
1762
+ res,
1763
+ 201,
1764
+ await importPageFromSource(sourcePath, name, route)
1765
+ )
1766
+ } catch (error) {
1767
+ sendJson(res, getErrorStatus(error), {
1768
+ error:
1769
+ error instanceof Error
1770
+ ? error.message
1771
+ : 'Failed to import page source',
1772
+ })
1773
+ }
1774
+ })
1775
+
1776
+ server.middlewares.use(
1777
+ '/__visualbuild/register-component',
1778
+ async (req, res) => {
1779
+ try {
1780
+ if (req.method !== 'POST') {
1781
+ res.statusCode = 405
1782
+ res.end()
1783
+ return
1784
+ }
1785
+
1786
+ const payload = await readJsonBody(req)
1787
+ const sourcePath =
1788
+ typeof payload.path === 'string' ? payload.path : ''
1789
+ sendJson(
1790
+ res,
1791
+ 201,
1792
+ await registerComponentFromSource(sourcePath)
1793
+ )
1794
+ } catch (error) {
1795
+ sendJson(res, getErrorStatus(error), {
1796
+ error:
1797
+ error instanceof Error
1798
+ ? error.message
1799
+ : 'Failed to register component source',
1800
+ })
1801
+ }
1802
+ }
1803
+ )
1804
+
1805
+ server.middlewares.use('/__visualbuild/tailwind', (req, res) => {
1806
+ if (req.method !== 'POST') {
1807
+ res.statusCode = 405
1808
+ res.end()
1809
+ return
1810
+ }
1811
+
1812
+ let body = ''
1813
+
1814
+ req.on('data', (chunk) => {
1815
+ body += chunk
1816
+ })
1817
+
1818
+ req.on('end', async () => {
1819
+ try {
1820
+ const current = await fs
1821
+ .readFile(runtimeClassesPath, 'utf8')
1822
+ .catch(() => '')
1823
+
1824
+ if (current !== body) {
1825
+ suppressReloadUntil = Date.now() + 1500
1826
+ await fs.writeFile(runtimeClassesPath, body)
1827
+ }
1828
+
1829
+ res.statusCode = 204
1830
+ res.end()
1831
+ } catch (error) {
1832
+ res.statusCode = 500
1833
+ res.end(
1834
+ error instanceof Error
1835
+ ? error.message
1836
+ : 'Failed to update Tailwind classes'
1837
+ )
1838
+ }
1839
+ })
1840
+ })
1841
+
1842
+ server.middlewares.use('/__visualbuild/pages', async (req, res) => {
1843
+ if (req.method === 'GET') {
1844
+ try {
1845
+ const json = await fs.readFile(pagesPath, 'utf8')
1846
+ res.setHeader('Content-Type', 'application/json')
1847
+ res.setHeader(
1848
+ 'X-VisualBuild-Project-Id',
1849
+ encodeURIComponent(projectRoot)
1850
+ )
1851
+ res.end(json)
1852
+ } catch (error) {
1853
+ res.statusCode = 500
1854
+ res.end(
1855
+ error instanceof Error
1856
+ ? error.message
1857
+ : 'Failed to read pages.json'
1858
+ )
1859
+ }
1860
+ return
1861
+ }
1862
+
1863
+ if (req.method === 'POST') {
1864
+ let body = ''
1865
+
1866
+ req.on('data', (chunk) => {
1867
+ body += chunk
1868
+ })
1869
+
1870
+ req.on('end', async () => {
1871
+ let previousSchema = ''
1872
+ let wroteSchema = false
1873
+
1874
+ try {
1875
+ await fs.mkdir(path.dirname(pagesPath), { recursive: true })
1876
+ previousSchema = await fs
1877
+ .readFile(pagesPath, 'utf8')
1878
+ .catch(() => '')
1879
+
1880
+ if (previousSchema !== body) {
1881
+ suppressWatchUntil = Date.now() + 1000
1882
+ suppressReloadUntil = Date.now() + 5000
1883
+ await fs.writeFile(pagesPath, body)
1884
+ wroteSchema = true
1885
+ await generateProjectFiles('pages.json write')
1886
+ suppressReloadUntil = Date.now() + 1000
1887
+ }
1888
+
1889
+ res.statusCode = 204
1890
+ res.end()
1891
+ } catch (error) {
1892
+ if (wroteSchema) {
1893
+ suppressWatchUntil = Date.now() + 1000
1894
+
1895
+ if (previousSchema) {
1896
+ await fs.writeFile(pagesPath, previousSchema)
1897
+ await generateProjectFiles('pages.json rollback').catch(
1898
+ (rollbackError) => {
1899
+ server.config.logger.error(
1900
+ `[VisualBuild] Failed to restore generated files: ${
1901
+ rollbackError instanceof Error
1902
+ ? rollbackError.message
1903
+ : String(rollbackError)
1904
+ }`
1905
+ )
1906
+ }
1907
+ )
1908
+ } else {
1909
+ await fs.rm(pagesPath, { force: true })
1910
+ }
1911
+ }
1912
+
1913
+ const diagnostic = getGeneratorDiagnostic(error)
1914
+ sendJson(res, 500, {
1915
+ error:
1916
+ diagnostic?.message ??
1917
+ (error instanceof Error
1918
+ ? error.message
1919
+ : 'Failed to write pages.json'),
1920
+ diagnostic,
1921
+ })
1922
+ }
1923
+ })
1924
+
1925
+ return
1926
+ }
1927
+
1928
+ res.statusCode = 405
1929
+ res.end()
1930
+ })
1931
+ },
1932
+ }
1933
+ }
1934
+
1935
+ export default defineConfig({
1936
+ root: builderRoot,
1937
+ define: {
1938
+ 'process.env.BABEL_TYPES_8_BREAKING': 'false',
1939
+ },
1940
+ plugins: [react(), tailwindcss(), visualbuildDevApi()],
1941
+ server: {
1942
+ watch: {
1943
+ ignored: ['**/node_modules/**', isGeneratedProjectPath],
1944
+ },
1945
+ },
1946
+ })