create-visualbuild-app 0.1.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 +123 -0
  2. package/package.json +85 -0
  3. package/scripts/create-builder-app.mjs +501 -0
  4. package/scripts/vb-dev.mjs +32 -0
  5. package/scripts/vb-generate.mjs +224 -0
  6. package/templates/default/app/README.md +21 -0
  7. package/templates/default/app/eslint.config.js +25 -0
  8. package/templates/default/app/index.html +15 -0
  9. package/templates/default/app/src/index.css +23 -0
  10. package/templates/default/app/src/main.tsx +10 -0
  11. package/templates/default/app/tsconfig.app.json +21 -0
  12. package/templates/default/app/tsconfig.json +7 -0
  13. package/templates/default/app/tsconfig.node.json +19 -0
  14. package/templates/default/app/visualbuild/generated-files.json +3 -0
  15. package/templates/default/app/visualbuild/pages.json +12 -0
  16. package/templates/default/app/vite.config.ts +7 -0
  17. package/templates/default/builder/README.md +21 -0
  18. package/templates/default/builder/eslint.config.js +25 -0
  19. package/templates/default/builder/tsconfig.app.json +21 -0
  20. package/templates/default/builder/tsconfig.json +7 -0
  21. package/templates/default/builder/tsconfig.node.json +22 -0
  22. package/visual-app-builder/index.html +15 -0
  23. package/visual-app-builder/server/parseReactPage.ts +571 -0
  24. package/visual-app-builder/shared/generateReactSource.d.mts +37 -0
  25. package/visual-app-builder/shared/generateReactSource.mjs +443 -0
  26. package/visual-app-builder/src/App.tsx +874 -0
  27. package/visual-app-builder/src/components/Canvas.tsx +1059 -0
  28. package/visual-app-builder/src/components/CodePanel.tsx +812 -0
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +302 -0
  30. package/visual-app-builder/src/components/PageSwitcher.tsx +161 -0
  31. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -0
  32. package/visual-app-builder/src/components/PropertiesPanel.tsx +692 -0
  33. package/visual-app-builder/src/components/Topbar.tsx +128 -0
  34. package/visual-app-builder/src/data/componentRegistry.tsx +292 -0
  35. package/visual-app-builder/src/data/primitiveRegistry.ts +368 -0
  36. package/visual-app-builder/src/index.css +111 -0
  37. package/visual-app-builder/src/main.tsx +10 -0
  38. package/visual-app-builder/src/stores/useAppStore.ts +1265 -0
  39. package/visual-app-builder/src/tailwindGeneratedSafelist.ts +36 -0
  40. package/visual-app-builder/src/types/index.ts +261 -0
  41. package/visual-app-builder/src/utils/codegen.ts +66 -0
  42. package/visual-app-builder/src/utils/projectFiles.ts +146 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +177 -0
  44. package/visual-app-builder/vite.config.ts +1479 -0
@@ -0,0 +1,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 { 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
+ })