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.
- package/README.md +306 -123
- package/docs/user-guide.md +759 -0
- package/package.json +92 -85
- package/scripts/create-builder-app.mjs +513 -501
- package/scripts/vb-dev.mjs +41 -32
- package/scripts/vb-generate.mjs +332 -224
- package/templates/default/app/.vercelignore +6 -0
- package/templates/default/app/README.md +56 -21
- package/templates/default/app/visualbuild/components.json +3 -0
- package/templates/default/app/visualbuild/config.d.ts +48 -0
- package/templates/default/app/visualbuild.config.ts +38 -0
- package/templates/default/builder/README.md +37 -21
- package/visual-app-builder/server/parseReactPage.ts +776 -571
- package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
- package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
- package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
- package/visual-app-builder/shared/generateReactSource.mjs +608 -443
- package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
- package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
- package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
- package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
- package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
- package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
- package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
- package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
- package/visual-app-builder/src/App.tsx +1090 -874
- package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
- package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
- package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
- package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
- package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
- package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
- package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
- package/visual-app-builder/src/components/Topbar.tsx +257 -128
- package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
- package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
- package/visual-app-builder/src/index.css +383 -111
- package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
- package/visual-app-builder/src/theme.ts +71 -0
- package/visual-app-builder/src/types/index.ts +350 -261
- package/visual-app-builder/src/utils/codegen.ts +90 -66
- package/visual-app-builder/src/utils/deployment.ts +54 -0
- package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
- package/visual-app-builder/vite.config.ts +1946 -1479
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface NpmBuildCommand {
|
|
2
|
+
executable: string
|
|
3
|
+
args: string[]
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface NpmBuildCommandEnvironment {
|
|
7
|
+
npm_execpath?: string
|
|
8
|
+
ComSpec?: string
|
|
9
|
+
COMSPEC?: string
|
|
10
|
+
[key: string]: string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveNpmBuildCommand(options?: {
|
|
14
|
+
platform?: NodeJS.Platform
|
|
15
|
+
env?: NpmBuildCommandEnvironment
|
|
16
|
+
nodeExecutable?: string
|
|
17
|
+
}): NpmBuildCommand
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function resolveNpmBuildCommand({
|
|
2
|
+
platform = process.platform,
|
|
3
|
+
env = process.env,
|
|
4
|
+
nodeExecutable = process.execPath,
|
|
5
|
+
} = {}) {
|
|
6
|
+
const npmCliPath = String(env.npm_execpath ?? '').trim()
|
|
7
|
+
|
|
8
|
+
if (npmCliPath) {
|
|
9
|
+
return {
|
|
10
|
+
executable: nodeExecutable,
|
|
11
|
+
args: [npmCliPath, 'run', 'build'],
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (platform === 'win32') {
|
|
16
|
+
return {
|
|
17
|
+
executable: env.ComSpec || env.COMSPEC || 'cmd.exe',
|
|
18
|
+
args: ['/d', '/s', '/c', 'npm.cmd run build'],
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
executable: 'npm',
|
|
24
|
+
args: ['run', 'build'],
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface CustomComponentRootSettings {
|
|
2
|
+
name: string
|
|
3
|
+
rootTag?: string
|
|
4
|
+
id?: unknown
|
|
5
|
+
className?: unknown
|
|
6
|
+
attributes?: unknown
|
|
7
|
+
children?: unknown[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function syncCustomComponentRoot(
|
|
11
|
+
source: string,
|
|
12
|
+
settings: CustomComponentRootSettings
|
|
13
|
+
): string
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { generate } from '@babel/generator'
|
|
2
|
+
import { parse } from '@babel/parser'
|
|
3
|
+
import * as t from '@babel/types'
|
|
4
|
+
|
|
5
|
+
const allowedRootTags = new Set([
|
|
6
|
+
'div',
|
|
7
|
+
'section',
|
|
8
|
+
'main',
|
|
9
|
+
'article',
|
|
10
|
+
'aside',
|
|
11
|
+
'header',
|
|
12
|
+
'footer',
|
|
13
|
+
'nav',
|
|
14
|
+
'form',
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
export function syncCustomComponentRoot(source, settings) {
|
|
18
|
+
const ast = parse(source, {
|
|
19
|
+
sourceType: 'module',
|
|
20
|
+
plugins: ['jsx', 'typescript'],
|
|
21
|
+
})
|
|
22
|
+
const root = findReturnedJsxElement(ast.program)
|
|
23
|
+
|
|
24
|
+
if (!root) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Registered component "${settings.name}" must return one JSX element`
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rootTag = allowedRootTags.has(String(settings.rootTag))
|
|
31
|
+
? String(settings.rootTag)
|
|
32
|
+
: 'div'
|
|
33
|
+
const additionalAttributes =
|
|
34
|
+
settings.attributes &&
|
|
35
|
+
typeof settings.attributes === 'object' &&
|
|
36
|
+
!Array.isArray(settings.attributes)
|
|
37
|
+
? settings.attributes
|
|
38
|
+
: {}
|
|
39
|
+
const serializedAttributes = Object.entries(additionalAttributes).filter(
|
|
40
|
+
([name, value]) =>
|
|
41
|
+
isSafeJsxAttributeName(name) &&
|
|
42
|
+
!['children', 'key', 'ref', 'id', 'className'].includes(name) &&
|
|
43
|
+
isSerializableAttributeValue(value)
|
|
44
|
+
)
|
|
45
|
+
const id = settings.id ? String(settings.id) : undefined
|
|
46
|
+
const className = settings.className
|
|
47
|
+
? String(settings.className)
|
|
48
|
+
: undefined
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
!Array.isArray(settings.children) &&
|
|
52
|
+
rootMatchesSettings(
|
|
53
|
+
root,
|
|
54
|
+
rootTag,
|
|
55
|
+
id,
|
|
56
|
+
className,
|
|
57
|
+
serializedAttributes
|
|
58
|
+
)
|
|
59
|
+
) {
|
|
60
|
+
return source
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
root.openingElement.name = t.jsxIdentifier(rootTag)
|
|
64
|
+
|
|
65
|
+
if (root.closingElement) {
|
|
66
|
+
root.closingElement.name = t.jsxIdentifier(rootTag)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const managedNames = new Set([
|
|
70
|
+
'id',
|
|
71
|
+
'className',
|
|
72
|
+
...Object.keys(additionalAttributes),
|
|
73
|
+
])
|
|
74
|
+
const preservedAttributes = root.openingElement.attributes.filter(
|
|
75
|
+
(attribute) =>
|
|
76
|
+
attribute.type === 'JSXSpreadAttribute' ||
|
|
77
|
+
!managedNames.has(attribute.name.name)
|
|
78
|
+
)
|
|
79
|
+
const nextAttributes = [
|
|
80
|
+
id ? createAttribute('id', id) : null,
|
|
81
|
+
className ? createAttribute('className', className) : null,
|
|
82
|
+
...serializedAttributes
|
|
83
|
+
.map(([name, value]) => createAttribute(name, value)),
|
|
84
|
+
].filter(Boolean)
|
|
85
|
+
|
|
86
|
+
root.openingElement.attributes = [
|
|
87
|
+
...nextAttributes,
|
|
88
|
+
...preservedAttributes,
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
if (Array.isArray(settings.children)) {
|
|
92
|
+
const children = settings.children
|
|
93
|
+
.map((child) => createVisualChild(child))
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
root.children =
|
|
96
|
+
children.length > 0 ? withLineBreaks(children, 1) : []
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return `${generate(ast, {
|
|
100
|
+
comments: true,
|
|
101
|
+
compact: false,
|
|
102
|
+
jsescOption: { minimal: true },
|
|
103
|
+
}).code}\n`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createVisualChild(rawNode) {
|
|
107
|
+
if (!rawNode || typeof rawNode !== 'object') return null
|
|
108
|
+
|
|
109
|
+
if (rawNode.type === '__visualbuild_children_slot__') {
|
|
110
|
+
return t.jsxExpressionContainer(t.identifier('children'))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const tagName = String(rawNode.type ?? 'div')
|
|
114
|
+
|
|
115
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$.-]*$/.test(tagName)) {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const props =
|
|
120
|
+
rawNode.props && typeof rawNode.props === 'object'
|
|
121
|
+
? rawNode.props
|
|
122
|
+
: {}
|
|
123
|
+
const attributeEntries = Object.entries(props).filter(
|
|
124
|
+
([name, value]) =>
|
|
125
|
+
![
|
|
126
|
+
'text',
|
|
127
|
+
'wrapperTag',
|
|
128
|
+
'componentTree',
|
|
129
|
+
'attributes',
|
|
130
|
+
].includes(name) &&
|
|
131
|
+
value !== undefined &&
|
|
132
|
+
value !== '' &&
|
|
133
|
+
isSafeJsxAttributeName(name) &&
|
|
134
|
+
isSerializableAttributeValue(value)
|
|
135
|
+
)
|
|
136
|
+
const additionalAttributes =
|
|
137
|
+
props.attributes &&
|
|
138
|
+
typeof props.attributes === 'object' &&
|
|
139
|
+
!Array.isArray(props.attributes)
|
|
140
|
+
? Object.entries(props.attributes).filter(
|
|
141
|
+
([name, value]) =>
|
|
142
|
+
isSafeJsxAttributeName(name) &&
|
|
143
|
+
isSerializableAttributeValue(value)
|
|
144
|
+
)
|
|
145
|
+
: []
|
|
146
|
+
const attributes = [...attributeEntries, ...additionalAttributes].map(
|
|
147
|
+
([name, value]) => createAttribute(name, value)
|
|
148
|
+
)
|
|
149
|
+
const isVoid = ['br', 'hr', 'img', 'input', 'source', 'col'].includes(
|
|
150
|
+
tagName
|
|
151
|
+
)
|
|
152
|
+
const opening = t.jsxOpeningElement(
|
|
153
|
+
t.jsxIdentifier(tagName),
|
|
154
|
+
attributes,
|
|
155
|
+
isVoid
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if (isVoid) {
|
|
159
|
+
return t.jsxElement(opening, null, [], true)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const nestedChildren = Array.isArray(rawNode.children)
|
|
163
|
+
? rawNode.children.map(createVisualChild).filter(Boolean)
|
|
164
|
+
: []
|
|
165
|
+
const text = String(props.text ?? '')
|
|
166
|
+
|
|
167
|
+
if (nestedChildren.length === 0 && text) {
|
|
168
|
+
nestedChildren.push(t.jsxText(text))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return t.jsxElement(
|
|
172
|
+
opening,
|
|
173
|
+
t.jsxClosingElement(t.jsxIdentifier(tagName)),
|
|
174
|
+
nestedChildren.length > 0
|
|
175
|
+
? withLineBreaks(nestedChildren, 2)
|
|
176
|
+
: [],
|
|
177
|
+
false
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function withLineBreaks(children, depth) {
|
|
182
|
+
const childIndent = ' '.repeat(depth)
|
|
183
|
+
const closingIndent = ' '.repeat(Math.max(0, depth - 1))
|
|
184
|
+
|
|
185
|
+
return [
|
|
186
|
+
t.jsxText(`\n${childIndent}`),
|
|
187
|
+
...children.flatMap((child, index) => [
|
|
188
|
+
child,
|
|
189
|
+
t.jsxText(
|
|
190
|
+
index === children.length - 1
|
|
191
|
+
? `\n${closingIndent}`
|
|
192
|
+
: `\n${childIndent}`
|
|
193
|
+
),
|
|
194
|
+
]),
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function rootMatchesSettings(
|
|
199
|
+
root,
|
|
200
|
+
rootTag,
|
|
201
|
+
id,
|
|
202
|
+
className,
|
|
203
|
+
additionalAttributes
|
|
204
|
+
) {
|
|
205
|
+
if (
|
|
206
|
+
root.openingElement.name.type !== 'JSXIdentifier' ||
|
|
207
|
+
root.openingElement.name.name !== rootTag
|
|
208
|
+
) {
|
|
209
|
+
return false
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const attributes = root.openingElement.attributes
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
attributeMatches(attributes, 'id', id) &&
|
|
216
|
+
attributeMatches(attributes, 'className', className) &&
|
|
217
|
+
additionalAttributes.every(([name, value]) =>
|
|
218
|
+
attributeMatches(attributes, name, value)
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function attributeMatches(attributes, name, expected) {
|
|
224
|
+
const attribute = attributes.find(
|
|
225
|
+
(candidate) =>
|
|
226
|
+
candidate.type === 'JSXAttribute' &&
|
|
227
|
+
candidate.name.type === 'JSXIdentifier' &&
|
|
228
|
+
candidate.name.name === name
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if (expected === undefined) {
|
|
232
|
+
return attribute === undefined
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!attribute) return false
|
|
236
|
+
return JSON.stringify(readAttributeValue(attribute)) === JSON.stringify(expected)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function readAttributeValue(attribute) {
|
|
240
|
+
if (!attribute.value) return true
|
|
241
|
+
if (attribute.value.type === 'StringLiteral') {
|
|
242
|
+
return attribute.value.value
|
|
243
|
+
}
|
|
244
|
+
if (attribute.value.type !== 'JSXExpressionContainer') {
|
|
245
|
+
return undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return readExpressionValue(attribute.value.expression)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readExpressionValue(expression) {
|
|
252
|
+
if (
|
|
253
|
+
expression.type === 'StringLiteral' ||
|
|
254
|
+
expression.type === 'NumericLiteral' ||
|
|
255
|
+
expression.type === 'BooleanLiteral'
|
|
256
|
+
) {
|
|
257
|
+
return expression.value
|
|
258
|
+
}
|
|
259
|
+
if (expression.type === 'ArrayExpression') {
|
|
260
|
+
return expression.elements.map((element) =>
|
|
261
|
+
element ? readExpressionValue(element) : null
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return undefined
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function findReturnedJsxElement(program) {
|
|
269
|
+
let result = null
|
|
270
|
+
|
|
271
|
+
const visit = (value) => {
|
|
272
|
+
if (result || !value || typeof value !== 'object') return
|
|
273
|
+
|
|
274
|
+
if (value.type === 'ReturnStatement') {
|
|
275
|
+
const argument = unwrapExpression(value.argument)
|
|
276
|
+
if (argument?.type === 'JSXElement') {
|
|
277
|
+
result = argument
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (
|
|
283
|
+
value.type === 'ArrowFunctionExpression' &&
|
|
284
|
+
unwrapExpression(value.body)?.type === 'JSXElement'
|
|
285
|
+
) {
|
|
286
|
+
result = unwrapExpression(value.body)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const child of Object.values(value)) {
|
|
291
|
+
if (Array.isArray(child)) {
|
|
292
|
+
child.forEach(visit)
|
|
293
|
+
} else {
|
|
294
|
+
visit(child)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
visit(program)
|
|
300
|
+
return result
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function unwrapExpression(value) {
|
|
304
|
+
if (
|
|
305
|
+
value &&
|
|
306
|
+
[
|
|
307
|
+
'ParenthesizedExpression',
|
|
308
|
+
'TSAsExpression',
|
|
309
|
+
'TSTypeAssertion',
|
|
310
|
+
'TSSatisfiesExpression',
|
|
311
|
+
].includes(value.type)
|
|
312
|
+
) {
|
|
313
|
+
return unwrapExpression(value.expression)
|
|
314
|
+
}
|
|
315
|
+
return value
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function createAttribute(name, value) {
|
|
319
|
+
return t.jsxAttribute(
|
|
320
|
+
t.jsxIdentifier(name),
|
|
321
|
+
t.jsxExpressionContainer(toExpression(value))
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function toExpression(value) {
|
|
326
|
+
if (typeof value === 'number') return t.numericLiteral(value)
|
|
327
|
+
if (typeof value === 'boolean') return t.booleanLiteral(value)
|
|
328
|
+
if (Array.isArray(value)) {
|
|
329
|
+
return t.arrayExpression(value.map(toExpression))
|
|
330
|
+
}
|
|
331
|
+
return t.stringLiteral(String(value))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isSafeJsxAttributeName(name) {
|
|
335
|
+
return /^[A-Za-z_:][A-Za-z0-9_.:-]*$/.test(name)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function isSerializableAttributeValue(value) {
|
|
339
|
+
return (
|
|
340
|
+
typeof value === 'string' ||
|
|
341
|
+
typeof value === 'number' ||
|
|
342
|
+
typeof value === 'boolean' ||
|
|
343
|
+
Array.isArray(value)
|
|
344
|
+
)
|
|
345
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface StaticDeploymentFile {
|
|
2
|
+
file: string
|
|
3
|
+
data: Buffer
|
|
4
|
+
sha: string
|
|
5
|
+
size: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface VercelDeploymentResult {
|
|
9
|
+
id: string
|
|
10
|
+
url: string
|
|
11
|
+
inspectorUrl?: string
|
|
12
|
+
readyState: 'READY'
|
|
13
|
+
fileCount: number
|
|
14
|
+
totalBytes: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateVercelProjectName(value: unknown): string
|
|
18
|
+
export function validateVercelTeamId(value: unknown): string | undefined
|
|
19
|
+
export function collectStaticDeploymentFiles(
|
|
20
|
+
outputRoot: string
|
|
21
|
+
): Promise<StaticDeploymentFile[]>
|
|
22
|
+
export function deployStaticOutputToVercel(options: {
|
|
23
|
+
outputRoot: string
|
|
24
|
+
token: string
|
|
25
|
+
projectName: string
|
|
26
|
+
teamId?: string
|
|
27
|
+
fetchImpl?: typeof fetch
|
|
28
|
+
pollIntervalMs?: number
|
|
29
|
+
maxPolls?: number
|
|
30
|
+
wait?: (milliseconds: number) => Promise<unknown>
|
|
31
|
+
}): Promise<VercelDeploymentResult>
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const vercelApiBase = 'https://api.vercel.com'
|
|
6
|
+
const forbiddenDeploymentSegments = new Set([
|
|
7
|
+
'.git',
|
|
8
|
+
'.vercel',
|
|
9
|
+
'node_modules',
|
|
10
|
+
'visual-app-builder',
|
|
11
|
+
'visual-builder',
|
|
12
|
+
'visualbuild',
|
|
13
|
+
])
|
|
14
|
+
const terminalDeploymentStates = new Set(['READY', 'ERROR', 'CANCELED'])
|
|
15
|
+
|
|
16
|
+
export function validateVercelProjectName(value) {
|
|
17
|
+
const projectName = String(value ?? '').trim().toLowerCase()
|
|
18
|
+
|
|
19
|
+
if (
|
|
20
|
+
!projectName ||
|
|
21
|
+
projectName.length > 100 ||
|
|
22
|
+
!/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/.test(projectName) ||
|
|
23
|
+
projectName.includes('---')
|
|
24
|
+
) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'Vercel project name must use lowercase letters, numbers, dots, underscores, or hyphens'
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return projectName
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateVercelTeamId(value) {
|
|
34
|
+
const teamId = String(value ?? '').trim()
|
|
35
|
+
|
|
36
|
+
if (!teamId) return undefined
|
|
37
|
+
if (!/^team_[A-Za-z0-9]+$/.test(teamId)) {
|
|
38
|
+
throw new Error('Vercel Team ID must start with "team_"')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return teamId
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function collectStaticDeploymentFiles(outputRoot) {
|
|
45
|
+
const absoluteRoot = path.resolve(outputRoot)
|
|
46
|
+
const rootStats = await fs.stat(absoluteRoot).catch(() => null)
|
|
47
|
+
|
|
48
|
+
if (!rootStats?.isDirectory()) {
|
|
49
|
+
throw new Error('The app build did not create its deployment output folder')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const files = []
|
|
53
|
+
|
|
54
|
+
async function visit(directoryPath, relativeDirectory = '') {
|
|
55
|
+
const entries = await fs.readdir(directoryPath, { withFileTypes: true })
|
|
56
|
+
|
|
57
|
+
for (const entry of entries.sort((first, second) =>
|
|
58
|
+
first.name.localeCompare(second.name)
|
|
59
|
+
)) {
|
|
60
|
+
const relativePath = relativeDirectory
|
|
61
|
+
? `${relativeDirectory}/${entry.name}`
|
|
62
|
+
: entry.name
|
|
63
|
+
const absolutePath = path.join(directoryPath, entry.name)
|
|
64
|
+
|
|
65
|
+
assertProductionPath(relativePath)
|
|
66
|
+
|
|
67
|
+
if (entry.isSymbolicLink()) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Deployment output cannot contain symbolic links (${relativePath})`
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (entry.isDirectory()) {
|
|
74
|
+
await visit(absolutePath, relativePath)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!entry.isFile()) continue
|
|
79
|
+
|
|
80
|
+
const data = await fs.readFile(absolutePath)
|
|
81
|
+
files.push({
|
|
82
|
+
file: relativePath,
|
|
83
|
+
data,
|
|
84
|
+
sha: createHash('sha1').update(data).digest('hex'),
|
|
85
|
+
size: data.byteLength,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await visit(absoluteRoot)
|
|
91
|
+
|
|
92
|
+
if (!files.some(({ file }) => file === 'index.html')) {
|
|
93
|
+
throw new Error('Deployment output must contain an index.html file')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!files.some(({ file }) => file === 'vercel.json')) {
|
|
97
|
+
const data = Buffer.from(
|
|
98
|
+
`${JSON.stringify(
|
|
99
|
+
{
|
|
100
|
+
version: 2,
|
|
101
|
+
routes: [
|
|
102
|
+
{ handle: 'filesystem' },
|
|
103
|
+
{ src: '/.*', dest: '/index.html' },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
null,
|
|
107
|
+
2
|
|
108
|
+
)}\n`
|
|
109
|
+
)
|
|
110
|
+
files.push({
|
|
111
|
+
file: 'vercel.json',
|
|
112
|
+
data,
|
|
113
|
+
sha: createHash('sha1').update(data).digest('hex'),
|
|
114
|
+
size: data.byteLength,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return files.sort((first, second) => first.file.localeCompare(second.file))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function deployStaticOutputToVercel({
|
|
122
|
+
outputRoot,
|
|
123
|
+
token,
|
|
124
|
+
projectName,
|
|
125
|
+
teamId,
|
|
126
|
+
fetchImpl = fetch,
|
|
127
|
+
pollIntervalMs = 1500,
|
|
128
|
+
maxPolls = 200,
|
|
129
|
+
wait = (milliseconds) =>
|
|
130
|
+
new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
131
|
+
}) {
|
|
132
|
+
const accessToken = String(token ?? '').trim()
|
|
133
|
+
|
|
134
|
+
if (!accessToken) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
'Set VERCEL_TOKEN in the visual-builder terminal, then restart the builder'
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const name = validateVercelProjectName(projectName)
|
|
141
|
+
const scope = validateVercelTeamId(teamId)
|
|
142
|
+
const files = await collectStaticDeploymentFiles(outputRoot)
|
|
143
|
+
const query = scope ? `?teamId=${encodeURIComponent(scope)}` : ''
|
|
144
|
+
const authorization = `Bearer ${accessToken}`
|
|
145
|
+
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const response = await fetchImpl(`${vercelApiBase}/v2/files${query}`, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: {
|
|
150
|
+
Authorization: authorization,
|
|
151
|
+
'Content-Type': 'application/octet-stream',
|
|
152
|
+
'Content-Length': String(file.size),
|
|
153
|
+
'x-vercel-digest': file.sha,
|
|
154
|
+
},
|
|
155
|
+
body: file.data,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw await createVercelApiError(response, `upload ${file.file}`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const deploymentResponse = await fetchImpl(
|
|
164
|
+
`${vercelApiBase}/v13/deployments${query}`,
|
|
165
|
+
{
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: {
|
|
168
|
+
Authorization: authorization,
|
|
169
|
+
'Content-Type': 'application/json',
|
|
170
|
+
},
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
name,
|
|
173
|
+
target: 'production',
|
|
174
|
+
files: files.map(({ file, sha, size }) => ({ file, sha, size })),
|
|
175
|
+
projectSettings: {
|
|
176
|
+
framework: null,
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if (!deploymentResponse.ok) {
|
|
183
|
+
throw await createVercelApiError(deploymentResponse, 'create deployment')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let deployment = await deploymentResponse.json()
|
|
187
|
+
const deploymentId = deployment.id ?? deployment.uid
|
|
188
|
+
const initialInspectorUrl = deployment.inspectorUrl
|
|
189
|
+
|
|
190
|
+
if (!deploymentId || !deployment.url) {
|
|
191
|
+
throw new Error('Vercel returned an incomplete deployment response')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
|
|
195
|
+
const state = String(
|
|
196
|
+
deployment.readyState ?? deployment.status ?? deployment.state ?? ''
|
|
197
|
+
).toUpperCase()
|
|
198
|
+
|
|
199
|
+
if (terminalDeploymentStates.has(state)) {
|
|
200
|
+
if (state !== 'READY') {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Vercel deployment ${state.toLowerCase()}. Open the deployment inspector for build details.`
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id: deploymentId,
|
|
208
|
+
url: normalizeDeploymentUrl(deployment.url),
|
|
209
|
+
inspectorUrl: deployment.inspectorUrl ?? initialInspectorUrl,
|
|
210
|
+
readyState: state,
|
|
211
|
+
fileCount: files.length,
|
|
212
|
+
totalBytes: files.reduce((total, file) => total + file.size, 0),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await wait(pollIntervalMs)
|
|
217
|
+
const statusResponse = await fetchImpl(
|
|
218
|
+
`${vercelApiBase}/v13/deployments/${encodeURIComponent(deploymentId)}${query}`,
|
|
219
|
+
{
|
|
220
|
+
headers: { Authorization: authorization },
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if (!statusResponse.ok) {
|
|
225
|
+
throw await createVercelApiError(statusResponse, 'read deployment status')
|
|
226
|
+
}
|
|
227
|
+
deployment = await statusResponse.json()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
throw new Error(
|
|
231
|
+
'Vercel is still building. Open the deployment dashboard to follow its status.'
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertProductionPath(relativePath) {
|
|
236
|
+
const normalized = relativePath.replace(/\\/g, '/')
|
|
237
|
+
const segments = normalized.split('/')
|
|
238
|
+
|
|
239
|
+
if (
|
|
240
|
+
normalized.startsWith('/') ||
|
|
241
|
+
segments.some(
|
|
242
|
+
(segment) =>
|
|
243
|
+
segment === '..' || forbiddenDeploymentSegments.has(segment.toLowerCase())
|
|
244
|
+
) ||
|
|
245
|
+
/^visualbuild\.config\.[cm]?[jt]s$/i.test(path.posix.basename(normalized))
|
|
246
|
+
) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Builder-only path cannot enter a production deployment (${relativePath})`
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function createVercelApiError(response, operation) {
|
|
254
|
+
const payload = await response.json().catch(() => null)
|
|
255
|
+
const message = payload?.error?.message ?? payload?.message
|
|
256
|
+
|
|
257
|
+
return new Error(
|
|
258
|
+
message
|
|
259
|
+
? `Vercel could not ${operation}: ${message}`
|
|
260
|
+
: `Vercel could not ${operation} (HTTP ${response.status})`
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeDeploymentUrl(value) {
|
|
265
|
+
return /^https?:\/\//i.test(value) ? value : `https://${value}`
|
|
266
|
+
}
|