Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
W
WebAgent
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
WebAgent
Commits
0c1766d3
Commit
0c1766d3
authored
Jul 15, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add custom domain management
parent
f86dd1a6
Changes
19
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
1033 additions
and
21 deletions
+1033
-21
.env.example
.env.example
+19
-0
README.md
README.md
+3
-0
build-manager.ts
backend/src/build/build-manager.ts
+4
-0
config.ts
backend/src/config.ts
+24
-0
domain-deployment-service.ts
backend/src/domains/domain-deployment-service.ts
+59
-0
domain-provider.ts
backend/src/domains/domain-provider.ts
+133
-0
domain-repository.ts
backend/src/domains/domain-repository.ts
+147
-0
domain-routing-config.ts
backend/src/domains/domain-routing-config.ts
+40
-0
domain-service.ts
backend/src/domains/domain-service.ts
+165
-0
schemas.ts
backend/src/schemas.ts
+8
-0
server.ts
backend/src/server.ts
+83
-5
site-repository.ts
backend/src/sites/site-repository.ts
+1
-0
README.md
deploy/nginx/README.md
+39
-0
webagent.conf
deploy/nginx/webagent.conf
+62
-0
storage-convention.md
docs/storage-convention.md
+8
-2
App.tsx
frontend/src/App.tsx
+152
-13
api.ts
frontend/src/api.ts
+6
-1
styles.css
frontend/src/styles.css
+31
-0
index.ts
shared/src/index.ts
+49
-0
No files found.
.env.example
View file @
0c1766d3
...
@@ -7,6 +7,25 @@ WEBAGENT_USER_PASSWORD=user
...
@@ -7,6 +7,25 @@ WEBAGENT_USER_PASSWORD=user
WEBAGENT_SITES_DIR=../WebAgent-sites
WEBAGENT_SITES_DIR=../WebAgent-sites
# 已上线的静态产物独立保存,不会被测试环境构建覆盖。
# 已上线的静态产物独立保存,不会被测试环境构建覆盖。
WEBAGENT_PRODUCTION_DIR=../WebAgent-production
WEBAGENT_PRODUCTION_DIR=../WebAgent-production
# 自定义域名使用单独的根路径构建产物,不会覆盖上面的生产目录。
WEBAGENT_CUSTOM_DOMAINS_DIR=../WebAgent-custom-domains
# 用户需要把 CNAME 指向这个由你控制的接入域名,例如 domains.your-company.com。
WEBAGENT_DOMAIN_CNAME_TARGET=domains.webagent.local
# 根域名不支持 CNAME/ALIAS 时,可选配置稳定的边缘 IP。
WEBAGENT_DOMAIN_IPV4=
WEBAGENT_DOMAIN_IPV6=
# external 表示由外部 CDN/TLS 服务管理;也可填写 caddy 或 cloudflare 用于界面提示。
WEBAGENT_DOMAIN_HTTPS_MODE=external
# WEBAGENT_DOMAIN_HTTPS_MODE=cloudflare 时必填,需要 Custom Hostnames 编辑权限。
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ZONE_ID=
# 使用 Caddy On-Demand TLS 时,保护 /internal/domains/allow 授权端点。
WEBAGENT_DOMAIN_ASK_TOKEN=
# 生产服务器可开启自动校验并 reload Nginx;本地没有运行 Nginx 时保持 false。
WEBAGENT_NGINX_AUTO_RELOAD=false
WEBAGENT_NGINX_BIN=nginx
# 待连接域名的所有权、证书和 DNS 自动检查间隔,最小 15000ms。
WEBAGENT_DOMAIN_CHECK_INTERVAL_MS=60000
# 可选:任意兼容 OpenAI Chat Completions 的模型服务。
# 可选:任意兼容 OpenAI Chat Completions 的模型服务。
# 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关,
# 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关,
# 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1
# 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1
...
...
README.md
View file @
0c1766d3
...
@@ -49,6 +49,9 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production
...
@@ -49,6 +49,9 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production
-
本地 iframe 预览与自动刷新
-
本地 iframe 预览与自动刷新
-
测试环境与生产环境使用独立目录和独立链接
-
测试环境与生产环境使用独立目录和独立链接
-
用户确认后手动发布,并记录生产版本 commit 与发布时间
-
用户确认后手动发布,并记录生产版本 commit 与发布时间
-
为每个官网绑定自定义域名,通过 TXT 验证所有权并检测 CNAME/A/AAAA
-
可接入 Cloudflare for SaaS 自动创建 Custom Hostname、展示证书验证记录并跟踪 HTTPS 状态
-
自定义域名使用独立根路径产物,域名故障不影响系统默认生产链接
-
浏览 Git 历史并安全回滚到任意版本
-
浏览 Git 历史并安全回滚到任意版本
-
生成站点保存在独立的可配置目录,构建缓存等临时数据隔离在
`.runtime/`
-
生成站点保存在独立的可配置目录,构建缓存等临时数据隔离在
`.runtime/`
...
...
backend/src/build/build-manager.ts
View file @
0c1766d3
...
@@ -42,6 +42,10 @@ export class BuildManager {
...
@@ -42,6 +42,10 @@ export class BuildManager {
return
this
.
publishAtomically
(
path
.
join
(
config
.
productionDir
,
siteId
),
distPath
);
return
this
.
publishAtomically
(
path
.
join
(
config
.
productionDir
,
siteId
),
distPath
);
}
}
async
publishCustomDomain
(
siteId
:
string
,
distPath
:
string
):
Promise
<
string
>
{
return
this
.
publishAtomically
(
path
.
join
(
config
.
customDomainsDir
,
siteId
),
distPath
);
}
async
ensureProductionPlaceholder
(
siteId
:
string
,
siteName
:
string
):
Promise
<
string
>
{
async
ensureProductionPlaceholder
(
siteId
:
string
,
siteName
:
string
):
Promise
<
string
>
{
const
target
=
path
.
join
(
config
.
productionDir
,
siteId
);
const
target
=
path
.
join
(
config
.
productionDir
,
siteId
);
const
indexPath
=
path
.
join
(
target
,
"index.html"
);
const
indexPath
=
path
.
join
(
target
,
"index.html"
);
...
...
backend/src/config.ts
View file @
0c1766d3
...
@@ -12,6 +12,14 @@ const configuredProductionDir = process.env.WEBAGENT_PRODUCTION_DIR?.trim() || "
...
@@ -12,6 +12,14 @@ const configuredProductionDir = process.env.WEBAGENT_PRODUCTION_DIR?.trim() || "
const
productionDir
=
path
.
isAbsolute
(
configuredProductionDir
)
const
productionDir
=
path
.
isAbsolute
(
configuredProductionDir
)
?
configuredProductionDir
?
configuredProductionDir
:
path
.
resolve
(
rootDir
,
configuredProductionDir
);
:
path
.
resolve
(
rootDir
,
configuredProductionDir
);
const
configuredCustomDomainsDir
=
process
.
env
.
WEBAGENT_CUSTOM_DOMAINS_DIR
?.
trim
()
||
"../WebAgent-custom-domains"
;
const
customDomainsDir
=
path
.
isAbsolute
(
configuredCustomDomainsDir
)
?
configuredCustomDomainsDir
:
path
.
resolve
(
rootDir
,
configuredCustomDomainsDir
);
const
configuredDomainCheckInterval
=
Number
(
process
.
env
.
WEBAGENT_DOMAIN_CHECK_INTERVAL_MS
||
60000
);
const
domainCheckIntervalMs
=
Number
.
isFinite
(
configuredDomainCheckInterval
)
?
Math
.
max
(
15000
,
configuredDomainCheckInterval
)
:
60000
;
export
const
config
=
{
export
const
config
=
{
rootDir
,
rootDir
,
...
@@ -19,6 +27,7 @@ export const config = {
...
@@ -19,6 +27,7 @@ export const config = {
templateDir
:
path
.
resolve
(
currentDir
,
"../../astro-template"
),
templateDir
:
path
.
resolve
(
currentDir
,
"../../astro-template"
),
sitesDir
,
sitesDir
,
productionDir
,
productionDir
,
customDomainsDir
,
host
:
process
.
env
.
HOST
||
"127.0.0.1"
,
host
:
process
.
env
.
HOST
||
"127.0.0.1"
,
port
:
Number
(
process
.
env
.
PORT
||
3100
),
port
:
Number
(
process
.
env
.
PORT
||
3100
),
frontendOrigin
:
process
.
env
.
FRONTEND_ORIGIN
||
"http://localhost:5173"
,
frontendOrigin
:
process
.
env
.
FRONTEND_ORIGIN
||
"http://localhost:5173"
,
...
@@ -31,6 +40,20 @@ export const config = {
...
@@ -31,6 +40,20 @@ export const config = {
baseUrl
:
(
process
.
env
.
OPENAI_BASE_URL
||
"https://api.deepseek.com/v1"
).
replace
(
/
\/
$/
,
""
),
baseUrl
:
(
process
.
env
.
OPENAI_BASE_URL
||
"https://api.deepseek.com/v1"
).
replace
(
/
\/
$/
,
""
),
model
:
process
.
env
.
OPENAI_MODEL
||
"deepseek-chat"
,
model
:
process
.
env
.
OPENAI_MODEL
||
"deepseek-chat"
,
},
},
domains
:
{
cnameTarget
:
(
process
.
env
.
WEBAGENT_DOMAIN_CNAME_TARGET
||
"domains.webagent.local"
).
trim
().
toLowerCase
().
replace
(
/
\.
$/
,
""
),
ipv4
:
process
.
env
.
WEBAGENT_DOMAIN_IPV4
?.
trim
()
||
""
,
ipv6
:
process
.
env
.
WEBAGENT_DOMAIN_IPV6
?.
trim
()
||
""
,
httpsMode
:
(
process
.
env
.
WEBAGENT_DOMAIN_HTTPS_MODE
||
"external"
)
as
"external"
|
"caddy"
|
"cloudflare"
,
askToken
:
process
.
env
.
WEBAGENT_DOMAIN_ASK_TOKEN
?.
trim
()
||
""
,
nginxAutoReload
:
process
.
env
.
WEBAGENT_NGINX_AUTO_RELOAD
===
"true"
,
nginxBin
:
process
.
env
.
WEBAGENT_NGINX_BIN
?.
trim
()
||
"nginx"
,
checkIntervalMs
:
domainCheckIntervalMs
,
cloudflare
:
{
apiToken
:
process
.
env
.
CLOUDFLARE_API_TOKEN
?.
trim
()
||
""
,
zoneId
:
process
.
env
.
CLOUDFLARE_ZONE_ID
?.
trim
()
||
""
,
},
},
};
};
export
const
runtimePaths
=
{
export
const
runtimePaths
=
{
...
@@ -40,6 +63,7 @@ export const runtimePaths = {
...
@@ -40,6 +63,7 @@ export const runtimePaths = {
uploads
:
path
.
join
(
config
.
runtimeDir
,
"uploads"
),
uploads
:
path
.
join
(
config
.
runtimeDir
,
"uploads"
),
pnpmStore
:
path
.
join
(
config
.
runtimeDir
,
"pnpm-store"
),
pnpmStore
:
path
.
join
(
config
.
runtimeDir
,
"pnpm-store"
),
logs
:
path
.
join
(
config
.
runtimeDir
,
"logs"
),
logs
:
path
.
join
(
config
.
runtimeDir
,
"logs"
),
domainMaps
:
path
.
join
(
config
.
runtimeDir
,
"nginx"
),
};
};
export
function
getPublicPreviewUrl
(
siteId
:
string
):
string
{
export
function
getPublicPreviewUrl
(
siteId
:
string
):
string
{
...
...
backend/src/domains/domain-deployment-service.ts
0 → 100644
View file @
0c1766d3
import
crypto
from
"node:crypto"
;
import
path
from
"node:path"
;
import
{
runtimePaths
}
from
"../config.js"
;
import
{
BuildError
,
BuildManager
}
from
"../build/build-manager.js"
;
import
{
GitManager
}
from
"../git/git-manager.js"
;
import
{
ensureEnvironmentConfig
}
from
"../sites/ensure-environment-config.js"
;
import
{
SiteRepository
}
from
"../sites/site-repository.js"
;
import
{
DomainRepository
}
from
"./domain-repository.js"
;
import
{
DomainRoutingConfig
}
from
"./domain-routing-config.js"
;
export
class
DomainDeploymentService
{
private
readonly
inFlight
=
new
Map
<
string
,
Promise
<
void
>>
();
constructor
(
private
readonly
sites
:
SiteRepository
,
private
readonly
domains
:
DomainRepository
,
private
readonly
git
:
GitManager
,
private
readonly
builds
:
BuildManager
,
private
readonly
routing
:
DomainRoutingConfig
,
)
{}
schedule
(
siteId
:
string
,
commit
:
string
):
Promise
<
void
>
{
const
current
=
this
.
inFlight
.
get
(
siteId
);
if
(
current
)
return
current
.
then
(()
=>
this
.
schedule
(
siteId
,
commit
));
const
operation
=
this
.
deploy
(
siteId
,
commit
).
finally
(()
=>
this
.
inFlight
.
delete
(
siteId
));
this
.
inFlight
.
set
(
siteId
,
operation
);
return
operation
;
}
private
async
deploy
(
siteId
:
string
,
commit
:
string
):
Promise
<
void
>
{
const
bindings
=
await
this
.
domains
.
list
(
siteId
);
if
(
!
bindings
.
some
((
domain
)
=>
domain
.
ownershipStatus
===
"verified"
))
return
;
const
project
=
this
.
sites
.
getProjectPath
(
siteId
);
await
this
.
git
.
assertCommit
(
project
,
commit
);
const
taskId
=
"domain_"
+
crypto
.
randomBytes
(
5
).
toString
(
"hex"
);
const
workspace
=
path
.
join
(
runtimePaths
.
builds
,
taskId
,
"workspace"
);
await
this
.
domains
.
updateSite
(
siteId
,
(
domain
)
=>
domain
.
ownershipStatus
===
"verified"
?
{
deploymentStatus
:
"deploying"
,
lastDeploymentError
:
undefined
}
:
{});
try
{
await
this
.
git
.
createWorktree
(
project
,
workspace
,
commit
);
await
ensureEnvironmentConfig
(
workspace
);
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
"/"
);
await
this
.
builds
.
publishCustomDomain
(
siteId
,
dist
);
await
this
.
domains
.
updateSite
(
siteId
,
(
domain
)
=>
domain
.
ownershipStatus
===
"verified"
?
{
deploymentStatus
:
"active"
,
deployedCommit
:
commit
,
lastDeploymentError
:
undefined
}
:
{});
await
this
.
routing
.
sync
();
}
catch
(
error
)
{
const
details
=
error
instanceof
BuildError
?
error
.
output
.
slice
(
-
3000
)
:
error
instanceof
Error
?
error
.
message
:
String
(
error
);
await
this
.
domains
.
updateSite
(
siteId
,
(
domain
)
=>
domain
.
ownershipStatus
===
"verified"
?
{
deploymentStatus
:
"failed"
,
lastDeploymentError
:
details
}
:
{});
throw
error
;
}
finally
{
await
this
.
git
.
removeWorktree
(
project
,
workspace
);
}
}
}
backend/src/domains/domain-provider.ts
0 → 100644
View file @
0c1766d3
import
type
{
DomainDnsRecord
,
DomainTlsStatus
}
from
"@webagent/shared"
;
import
{
config
}
from
"../config.js"
;
import
type
{
StoredDomainBinding
}
from
"./domain-repository.js"
;
export
interface
DomainProviderState
{
providerHostnameId
?:
string
;
providerStatus
?:
string
;
tlsStatus
:
DomainTlsStatus
;
certificateRecords
?:
DomainDnsRecord
[];
error
?:
string
;
}
export
interface
DomainProvider
{
isConfigured
():
boolean
;
initialTlsStatus
():
DomainTlsStatus
;
sync
(
domain
:
StoredDomainBinding
):
Promise
<
DomainProviderState
>
;
remove
(
domain
:
StoredDomainBinding
):
Promise
<
void
>
;
}
class
ManagedExternallyProvider
implements
DomainProvider
{
isConfigured
():
boolean
{
return
true
;
}
initialTlsStatus
():
DomainTlsStatus
{
return
"managed"
;
}
async
sync
():
Promise
<
DomainProviderState
>
{
return
{
tlsStatus
:
"managed"
,
providerStatus
:
config
.
domains
.
httpsMode
};
}
async
remove
():
Promise
<
void
>
{}
}
type
CloudflareValidationRecord
=
{
status
?:
string
;
txt_name
?:
string
;
txt_value
?:
string
;
cname
?:
string
;
cname_target
?:
string
;
};
type
CloudflareHostname
=
{
id
:
string
;
hostname
:
string
;
status
?:
string
;
ownership_verification
?:
{
type
?:
"txt"
;
name
?:
string
;
value
?:
string
};
verification_errors
?:
Array
<
string
|
{
message
?:
string
}
>
;
ssl
?:
{
status
?:
string
;
validation_records
?:
CloudflareValidationRecord
[];
validation_errors
?:
Array
<
string
|
{
message
?:
string
}
>
;
};
};
type
CloudflareResponse
<
T
>
=
{
success
?:
boolean
;
result
?:
T
;
errors
?:
Array
<
{
message
?:
string
}
>
;
};
class
CloudflareDomainProvider
implements
DomainProvider
{
isConfigured
():
boolean
{
return
Boolean
(
config
.
domains
.
cloudflare
.
apiToken
&&
config
.
domains
.
cloudflare
.
zoneId
);
}
initialTlsStatus
():
DomainTlsStatus
{
return
"pending"
;
}
async
sync
(
domain
:
StoredDomainBinding
):
Promise
<
DomainProviderState
>
{
if
(
!
this
.
isConfigured
())
throw
new
Error
(
"Cloudflare for SaaS 尚未配置 API Token 或 Zone ID"
);
let
hostname
:
CloudflareHostname
;
if
(
domain
.
providerHostnameId
)
{
hostname
=
await
this
.
request
<
CloudflareHostname
>
(
`/custom_hostnames/
${
domain
.
providerHostnameId
}
`
);
}
else
{
const
created
=
await
this
.
request
<
CloudflareHostname
>
(
"/custom_hostnames"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
hostname
:
domain
.
hostname
,
custom_metadata
:
{
site_id
:
domain
.
siteId
,
domain_id
:
domain
.
domainId
},
ssl
:
{
method
:
"txt"
,
type
:
"dv"
,
settings
:
{
min_tls_version
:
"1.2"
}
},
}),
});
hostname
=
await
this
.
request
<
CloudflareHostname
>
(
`/custom_hostnames/
${
created
.
id
}
`
);
}
return
this
.
toState
(
hostname
);
}
async
remove
(
domain
:
StoredDomainBinding
):
Promise
<
void
>
{
if
(
!
domain
.
providerHostnameId
||
!
this
.
isConfigured
())
return
;
await
this
.
request
<
{
id
?:
string
}
>
(
`/custom_hostnames/
${
domain
.
providerHostnameId
}
`
,
{
method
:
"DELETE"
});
}
private
async
request
<
T
>
(
pathname
:
string
,
options
?:
RequestInit
):
Promise
<
T
>
{
const
response
=
await
fetch
(
`https://api.cloudflare.com/client/v4/zones/
${
config
.
domains
.
cloudflare
.
zoneId
}${
pathname
}
`
,
{
...
options
,
signal
:
options
?.
signal
||
AbortSignal
.
timeout
(
15000
),
headers
:
{
authorization
:
`Bearer
${
config
.
domains
.
cloudflare
.
apiToken
}
`
,
"content-type"
:
"application/json"
,
...
options
?.
headers
,
},
});
const
data
=
await
response
.
json
()
as
CloudflareResponse
<
T
>
|
T
;
if
(
response
.
ok
&&
options
?.
method
===
"DELETE"
&&
"id"
in
(
data
as
object
))
return
data
as
T
;
const
envelope
=
data
as
CloudflareResponse
<
T
>
;
if
(
!
response
.
ok
||
!
envelope
.
success
||
!
envelope
.
result
)
{
throw
new
Error
(
envelope
.
errors
?.
map
((
error
)
=>
error
.
message
).
filter
(
Boolean
).
join
(
";"
)
||
`Cloudflare 请求失败(
${
response
.
status
}
)`
);
}
return
envelope
.
result
;
}
private
toState
(
hostname
:
CloudflareHostname
):
DomainProviderState
{
const
sslStatus
=
hostname
.
ssl
?.
status
||
"initializing"
;
const
hostnameStatus
=
hostname
.
status
||
"pending"
;
const
active
=
hostnameStatus
===
"active"
&&
sslStatus
===
"active"
;
const
sslFailed
=
sslStatus
!==
"active"
&&
sslStatus
!==
"initializing"
&&
!
sslStatus
.
startsWith
(
"pending"
);
const
hostnameFailed
=
hostnameStatus
!==
"active"
&&
hostnameStatus
!==
"active_redeploying"
&&
!
hostnameStatus
.
startsWith
(
"pending"
);
const
records
:
DomainDnsRecord
[]
=
[];
const
ownership
=
hostname
.
ownership_verification
;
if
(
ownership
?.
name
&&
ownership
.
value
)
records
.
push
({
type
:
"TXT"
,
name
:
ownership
.
name
,
value
:
ownership
.
value
,
purpose
:
"hostname"
});
for
(
const
record
of
hostname
.
ssl
?.
validation_records
||
[])
{
if
(
record
.
txt_name
&&
record
.
txt_value
)
records
.
push
({
type
:
"TXT"
,
name
:
record
.
txt_name
,
value
:
record
.
txt_value
,
purpose
:
"certificate"
});
else
if
(
record
.
cname
&&
record
.
cname_target
)
records
.
push
({
type
:
"CNAME"
,
name
:
record
.
cname
,
value
:
record
.
cname_target
,
purpose
:
"certificate"
});
}
const
unique
=
[...
new
Map
(
records
.
map
((
record
)
=>
[
`
${
record
.
type
}
:
${
record
.
name
}
:
${
record
.
value
}
`
,
record
])).
values
()];
const
errors
=
[...(
hostname
.
verification_errors
||
[]),
...(
hostname
.
ssl
?.
validation_errors
||
[])]
.
map
((
error
)
=>
typeof
error
===
"string"
?
error
:
error
.
message
)
.
filter
((
message
):
message
is
string
=>
Boolean
(
message
));
return
{
providerHostnameId
:
hostname
.
id
,
providerStatus
:
`hostname:
${
hostnameStatus
}
; ssl:
${
sslStatus
}
`
,
tlsStatus
:
active
?
"active"
:
sslFailed
||
hostnameFailed
?
"failed"
:
"pending"
,
certificateRecords
:
unique
,
error
:
errors
.
join
(
";"
)
||
undefined
,
};
}
}
export
function
createDomainProvider
():
DomainProvider
{
return
config
.
domains
.
httpsMode
===
"cloudflare"
?
new
CloudflareDomainProvider
()
:
new
ManagedExternallyProvider
();
}
backend/src/domains/domain-repository.ts
0 → 100644
View file @
0c1766d3
import
crypto
from
"node:crypto"
;
import
path
from
"node:path"
;
import
{
mkdir
,
readFile
,
readdir
,
rename
,
writeFile
}
from
"node:fs/promises"
;
import
type
{
DomainBinding
}
from
"@webagent/shared"
;
import
{
config
,
runtimePaths
}
from
"../config.js"
;
export
type
StoredDomainBinding
=
Omit
<
DomainBinding
,
"status"
|
"verificationHost"
|
"verificationValue"
|
"cnameTarget"
|
"url"
>
&
{
verificationToken
:
string
;
};
export
class
DomainError
extends
Error
{
constructor
(
message
:
string
,
public
readonly
statusCode
=
400
)
{
super
(
message
);
}
}
export
class
DomainRepository
{
private
mutationQueue
:
Promise
<
unknown
>
=
Promise
.
resolve
();
async
list
(
siteId
:
string
):
Promise
<
StoredDomainBinding
[]
>
{
this
.
assertSiteId
(
siteId
);
return
this
.
readSite
(
siteId
);
}
async
listAll
():
Promise
<
StoredDomainBinding
[]
>
{
const
entries
=
await
readdir
(
runtimePaths
.
sites
,
{
withFileTypes
:
true
}).
catch
(()
=>
[]);
const
groups
=
await
Promise
.
all
(
entries
.
filter
((
entry
)
=>
entry
.
isDirectory
()
&&
/^site_
[
a-z0-9
]
+$/
.
test
(
entry
.
name
))
.
map
((
entry
)
=>
this
.
readSite
(
entry
.
name
)));
return
groups
.
flat
();
}
async
get
(
siteId
:
string
,
domainId
:
string
):
Promise
<
StoredDomainBinding
>
{
const
domain
=
(
await
this
.
list
(
siteId
)).
find
((
item
)
=>
item
.
domainId
===
domainId
);
if
(
!
domain
)
throw
new
DomainError
(
"域名绑定不存在"
,
404
);
return
domain
;
}
async
findByHostname
(
hostname
:
string
):
Promise
<
StoredDomainBinding
|
undefined
>
{
return
(
await
this
.
listAll
()).
find
((
item
)
=>
item
.
hostname
===
hostname
);
}
async
create
(
siteId
:
string
,
hostname
:
string
,
verificationToken
:
string
,
tlsStatus
:
StoredDomainBinding
[
"tlsStatus"
]):
Promise
<
StoredDomainBinding
>
{
return
this
.
mutate
(
async
()
=>
{
if
((
await
this
.
listAll
()).
some
((
item
)
=>
item
.
hostname
===
hostname
))
throw
new
DomainError
(
"该域名已经绑定到其他官网"
,
409
);
const
current
=
await
this
.
readSite
(
siteId
);
const
now
=
new
Date
().
toISOString
();
const
domain
:
StoredDomainBinding
=
{
domainId
:
"domain_"
+
crypto
.
randomBytes
(
6
).
toString
(
"hex"
),
siteId
,
hostname
,
isPrimary
:
current
.
length
===
0
,
ownershipStatus
:
"pending"
,
dnsStatus
:
"pending"
,
deploymentStatus
:
"pending"
,
tlsStatus
,
verificationToken
,
createdAt
:
now
,
updatedAt
:
now
,
};
await
this
.
writeSite
(
siteId
,
[...
current
,
domain
]);
return
domain
;
});
}
async
update
(
siteId
:
string
,
domainId
:
string
,
values
:
Partial
<
StoredDomainBinding
>
):
Promise
<
StoredDomainBinding
>
{
return
this
.
mutate
(
async
()
=>
{
const
current
=
await
this
.
readSite
(
siteId
);
const
index
=
current
.
findIndex
((
item
)
=>
item
.
domainId
===
domainId
);
if
(
index
<
0
)
throw
new
DomainError
(
"域名绑定不存在"
,
404
);
const
next
=
{
...
current
[
index
],
...
values
,
domainId
,
siteId
,
updatedAt
:
new
Date
().
toISOString
()
};
current
[
index
]
=
next
;
await
this
.
writeSite
(
siteId
,
current
);
return
next
;
});
}
async
updateSite
(
siteId
:
string
,
values
:
(
domain
:
StoredDomainBinding
)
=>
Partial
<
StoredDomainBinding
>
):
Promise
<
StoredDomainBinding
[]
>
{
return
this
.
mutate
(
async
()
=>
{
const
current
=
await
this
.
readSite
(
siteId
);
const
updatedAt
=
new
Date
().
toISOString
();
const
next
=
current
.
map
((
domain
)
=>
({
...
domain
,
...
values
(
domain
),
domainId
:
domain
.
domainId
,
siteId
,
updatedAt
}));
await
this
.
writeSite
(
siteId
,
next
);
return
next
;
});
}
async
setPrimary
(
siteId
:
string
,
domainId
:
string
):
Promise
<
StoredDomainBinding
>
{
return
this
.
mutate
(
async
()
=>
{
const
current
=
await
this
.
readSite
(
siteId
);
if
(
!
current
.
some
((
item
)
=>
item
.
domainId
===
domainId
))
throw
new
DomainError
(
"域名绑定不存在"
,
404
);
const
updatedAt
=
new
Date
().
toISOString
();
const
next
=
current
.
map
((
item
)
=>
({
...
item
,
isPrimary
:
item
.
domainId
===
domainId
,
updatedAt
}));
await
this
.
writeSite
(
siteId
,
next
);
return
next
.
find
((
item
)
=>
item
.
domainId
===
domainId
)
!
;
});
}
async
remove
(
siteId
:
string
,
domainId
:
string
):
Promise
<
void
>
{
await
this
.
mutate
(
async
()
=>
{
const
current
=
await
this
.
readSite
(
siteId
);
const
removed
=
current
.
find
((
item
)
=>
item
.
domainId
===
domainId
);
if
(
!
removed
)
throw
new
DomainError
(
"域名绑定不存在"
,
404
);
const
next
=
current
.
filter
((
item
)
=>
item
.
domainId
!==
domainId
);
if
(
removed
.
isPrimary
&&
next
[
0
])
next
[
0
]
=
{
...
next
[
0
],
isPrimary
:
true
,
updatedAt
:
new
Date
().
toISOString
()
};
await
this
.
writeSite
(
siteId
,
next
);
});
}
private
getPath
(
siteId
:
string
):
string
{
this
.
assertSiteId
(
siteId
);
return
path
.
join
(
runtimePaths
.
sites
,
siteId
,
"metadata"
,
"domains.json"
);
}
private
async
readSite
(
siteId
:
string
):
Promise
<
StoredDomainBinding
[]
>
{
try
{
const
domains
=
JSON
.
parse
(
await
readFile
(
this
.
getPath
(
siteId
),
"utf8"
))
as
StoredDomainBinding
[];
return
domains
.
map
((
domain
)
=>
{
const
{
lastError
,
...
stored
}
=
domain
;
return
{
...
stored
,
tlsStatus
:
domain
.
tlsStatus
||
(
config
.
domains
.
httpsMode
===
"cloudflare"
?
"pending"
:
"managed"
),
lastDeploymentError
:
domain
.
lastDeploymentError
||
lastError
,
};
});
}
catch
(
error
)
{
if
(
error
instanceof
Error
&&
"code"
in
error
&&
error
.
code
===
"ENOENT"
)
return
[];
throw
error
;
}
}
private
async
writeSite
(
siteId
:
string
,
domains
:
StoredDomainBinding
[]):
Promise
<
void
>
{
const
target
=
this
.
getPath
(
siteId
);
await
mkdir
(
path
.
dirname
(
target
),
{
recursive
:
true
});
const
temporary
=
target
+
".tmp"
;
await
writeFile
(
temporary
,
JSON
.
stringify
(
domains
,
null
,
2
)
+
"
\n
"
,
"utf8"
);
await
rename
(
temporary
,
target
);
}
private
async
mutate
<
T
>
(
operation
:
()
=>
Promise
<
T
>
):
Promise
<
T
>
{
const
result
=
this
.
mutationQueue
.
then
(
operation
,
operation
);
this
.
mutationQueue
=
result
.
then
(()
=>
undefined
,
()
=>
undefined
);
return
result
;
}
private
assertSiteId
(
siteId
:
string
):
void
{
if
(
!
/^site_
[
a-z0-9
]
+$/
.
test
(
siteId
))
throw
new
DomainError
(
"无效的 siteId"
,
400
);
}
}
backend/src/domains/domain-routing-config.ts
0 → 100644
View file @
0c1766d3
import
path
from
"node:path"
;
import
{
mkdir
,
rename
,
writeFile
}
from
"node:fs/promises"
;
import
{
execa
}
from
"execa"
;
import
{
config
,
runtimePaths
}
from
"../config.js"
;
import
{
DomainRepository
}
from
"./domain-repository.js"
;
export
class
DomainRoutingConfig
{
constructor
(
private
readonly
domains
:
DomainRepository
)
{}
async
sync
():
Promise
<
void
>
{
const
active
=
(
await
this
.
domains
.
listAll
())
.
filter
((
domain
)
=>
domain
.
ownershipStatus
===
"verified"
&&
domain
.
deploymentStatus
===
"active"
)
.
sort
((
a
,
b
)
=>
a
.
hostname
.
localeCompare
(
b
.
hostname
));
const
primaryBySite
=
new
Map
<
string
,
string
>
();
for
(
const
domain
of
active
)
{
if
(
domain
.
isPrimary
||
!
primaryBySite
.
has
(
domain
.
siteId
))
primaryBySite
.
set
(
domain
.
siteId
,
domain
.
hostname
);
}
const
routes
=
active
.
map
((
domain
)
=>
`
${
domain
.
hostname
}
${
domain
.
siteId
}
;`
).
join
(
"
\n
"
);
const
redirects
=
active
.
filter
((
domain
)
=>
primaryBySite
.
get
(
domain
.
siteId
)
!==
domain
.
hostname
)
.
map
((
domain
)
=>
`
${
domain
.
hostname
}
${
primaryBySite
.
get
(
domain
.
siteId
)}
;`
)
.
join
(
"
\n
"
);
await
Promise
.
all
([
this
.
writeAtomically
(
path
.
join
(
runtimePaths
.
domainMaps
,
"domains"
,
"routes.map"
),
routes
),
this
.
writeAtomically
(
path
.
join
(
runtimePaths
.
domainMaps
,
"redirects"
,
"routes.map"
),
redirects
),
]);
if
(
config
.
domains
.
nginxAutoReload
)
{
await
execa
(
config
.
domains
.
nginxBin
,
[
"-t"
]);
await
execa
(
config
.
domains
.
nginxBin
,
[
"-s"
,
"reload"
]);
}
}
private
async
writeAtomically
(
target
:
string
,
content
:
string
):
Promise
<
void
>
{
await
mkdir
(
path
.
dirname
(
target
),
{
recursive
:
true
});
const
temporary
=
target
+
".tmp"
;
await
writeFile
(
temporary
,
(
content
||
"# No active custom domains"
)
+
"
\n
"
,
"utf8"
);
await
rename
(
temporary
,
target
);
}
}
backend/src/domains/domain-service.ts
0 → 100644
View file @
0c1766d3
import
crypto
from
"node:crypto"
;
import
{
promises
as
dns
}
from
"node:dns"
;
import
{
domainToASCII
}
from
"node:url"
;
import
type
{
DomainBinding
,
DomainListResult
,
DomainStatus
}
from
"@webagent/shared"
;
import
{
config
}
from
"../config.js"
;
import
{
SiteRepository
}
from
"../sites/site-repository.js"
;
import
{
DomainError
,
DomainRepository
,
type
StoredDomainBinding
}
from
"./domain-repository.js"
;
import
type
{
DomainProvider
}
from
"./domain-provider.js"
;
import
{
DomainRoutingConfig
}
from
"./domain-routing-config.js"
;
export
class
DomainService
{
constructor
(
private
readonly
sites
:
SiteRepository
,
private
readonly
domains
:
DomainRepository
,
private
readonly
routing
:
DomainRoutingConfig
,
private
readonly
provider
:
DomainProvider
,
)
{}
async
list
(
siteId
:
string
):
Promise
<
DomainListResult
>
{
await
this
.
sites
.
get
(
siteId
);
return
{
domains
:
(
await
this
.
domains
.
list
(
siteId
)).
map
((
domain
)
=>
this
.
present
(
domain
)),
cnameTarget
:
config
.
domains
.
cnameTarget
,
configured
:
!
config
.
domains
.
cnameTarget
.
endsWith
(
".local"
)
&&
this
.
provider
.
isConfigured
(),
ipv4
:
config
.
domains
.
ipv4
||
undefined
,
ipv6
:
config
.
domains
.
ipv6
||
undefined
,
httpsMode
:
config
.
domains
.
httpsMode
,
};
}
async
add
(
siteId
:
string
,
input
:
string
):
Promise
<
DomainBinding
>
{
await
this
.
sites
.
get
(
siteId
);
if
(
config
.
domains
.
cnameTarget
.
endsWith
(
".local"
))
throw
new
DomainError
(
"域名接入尚未配置,请先设置 WEBAGENT_DOMAIN_CNAME_TARGET"
);
if
(
!
this
.
provider
.
isConfigured
())
throw
new
DomainError
(
"域名 HTTPS Provider 尚未配置完整"
);
const
hostname
=
this
.
normalizeHostname
(
input
);
const
verificationToken
=
"webagent-verification="
+
crypto
.
randomBytes
(
18
).
toString
(
"base64url"
);
return
this
.
present
(
await
this
.
domains
.
create
(
siteId
,
hostname
,
verificationToken
,
this
.
provider
.
initialTlsStatus
()));
}
async
verify
(
siteId
:
string
,
domainId
:
string
):
Promise
<
DomainBinding
>
{
const
domain
=
await
this
.
domains
.
get
(
siteId
,
domainId
);
const
[
ownership
,
routing
]
=
await
Promise
.
all
([
domain
.
ownershipStatus
===
"verified"
?
Promise
.
resolve
<
{
valid
:
boolean
;
error
?:
string
}
>
({
valid
:
true
})
:
this
.
checkOwnership
(
domain
),
this
.
checkRouting
(
domain
.
hostname
),
]);
const
errors
=
[
ownership
.
error
,
routing
.
error
].
filter
(
Boolean
);
let
next
=
await
this
.
domains
.
update
(
siteId
,
domainId
,
{
ownershipStatus
:
ownership
.
valid
?
"verified"
:
"failed"
,
dnsStatus
:
routing
.
valid
?
"valid"
:
"invalid"
,
lastCheckedAt
:
new
Date
().
toISOString
(),
lastDnsError
:
errors
.
length
?
errors
.
join
(
";"
)
:
undefined
,
});
if
(
next
.
ownershipStatus
===
"verified"
)
{
try
{
const
provider
=
await
this
.
provider
.
sync
(
next
);
next
=
await
this
.
domains
.
update
(
siteId
,
domainId
,
{
providerHostnameId
:
provider
.
providerHostnameId
,
providerStatus
:
provider
.
providerStatus
,
tlsStatus
:
provider
.
tlsStatus
,
certificateRecords
:
provider
.
certificateRecords
,
lastDnsError
:
routing
.
error
,
lastTlsError
:
provider
.
error
,
});
}
catch
(
error
)
{
next
=
await
this
.
domains
.
update
(
siteId
,
domainId
,
{
tlsStatus
:
"failed"
,
lastTlsError
:
error
instanceof
Error
?
error
.
message
:
String
(
error
),
});
}
}
return
this
.
present
(
next
);
}
async
setPrimary
(
siteId
:
string
,
domainId
:
string
):
Promise
<
DomainBinding
>
{
const
domain
=
this
.
present
(
await
this
.
domains
.
setPrimary
(
siteId
,
domainId
));
await
this
.
routing
.
sync
();
return
domain
;
}
async
remove
(
siteId
:
string
,
domainId
:
string
):
Promise
<
void
>
{
const
domain
=
await
this
.
domains
.
get
(
siteId
,
domainId
);
await
this
.
provider
.
remove
(
domain
);
await
this
.
domains
.
remove
(
siteId
,
domainId
);
await
this
.
routing
.
sync
();
}
async
isCertificateAllowed
(
hostnameInput
:
string
):
Promise
<
boolean
>
{
let
hostname
:
string
;
try
{
hostname
=
this
.
normalizeHostname
(
hostnameInput
);
}
catch
{
return
false
;
}
const
domain
=
await
this
.
domains
.
findByHostname
(
hostname
);
return
Boolean
(
domain
&&
domain
.
ownershipStatus
===
"verified"
&&
domain
.
deploymentStatus
===
"active"
);
}
present
(
domain
:
StoredDomainBinding
):
DomainBinding
{
const
status
=
this
.
status
(
domain
);
const
{
verificationToken
,
lastError
:
_legacyError
,
...
stored
}
=
domain
;
return
{
...
stored
,
status
,
verificationHost
:
`_webagent-verification.
${
domain
.
hostname
}
`
,
verificationValue
:
verificationToken
,
cnameTarget
:
config
.
domains
.
cnameTarget
,
url
:
`https://
${
domain
.
hostname
}
/`
,
lastError
:
[
domain
.
lastDeploymentError
,
domain
.
lastTlsError
,
domain
.
lastDnsError
,
_legacyError
].
filter
(
Boolean
).
join
(
";"
)
||
undefined
,
};
}
private
status
(
domain
:
StoredDomainBinding
):
DomainStatus
{
if
(
domain
.
deploymentStatus
===
"failed"
)
return
"failed"
;
if
(
domain
.
tlsStatus
===
"failed"
)
return
"failed"
;
if
(
domain
.
ownershipStatus
!==
"verified"
)
return
"pending_verification"
;
if
(
domain
.
deploymentStatus
!==
"active"
)
return
"deploying"
;
if
(
domain
.
tlsStatus
===
"pending"
)
return
"provisioning_tls"
;
if
(
domain
.
dnsStatus
!==
"valid"
)
return
"pending_dns"
;
return
"active"
;
}
private
normalizeHostname
(
input
:
string
):
string
{
const
raw
=
input
.
trim
().
toLowerCase
().
replace
(
/
\.
$/
,
""
);
if
(
raw
.
includes
(
"://"
)
||
raw
.
includes
(
"/"
)
||
raw
.
includes
(
":"
)
||
raw
.
includes
(
"@"
))
throw
new
DomainError
(
"请输入域名,不要包含协议、路径或端口"
);
const
hostname
=
domainToASCII
(
raw
);
if
(
!
hostname
||
hostname
.
length
>
253
||
!
hostname
.
includes
(
"."
))
throw
new
DomainError
(
"请输入有效的完整域名"
);
const
labels
=
hostname
.
split
(
"."
);
if
(
labels
.
some
((
label
)
=>
!
label
||
label
.
length
>
63
||
!
/^
[
a-z0-9
](?:[
a-z0-9-
]
*
[
a-z0-9
])?
$/
.
test
(
label
)))
throw
new
DomainError
(
"域名格式不正确"
);
if
([
"local"
,
"localhost"
,
"test"
,
"invalid"
,
"example"
].
includes
(
labels
.
at
(
-
1
)
!
))
throw
new
DomainError
(
"不能绑定本地或保留域名"
);
return
hostname
;
}
private
async
checkOwnership
(
domain
:
StoredDomainBinding
):
Promise
<
{
valid
:
boolean
;
error
?:
string
}
>
{
const
host
=
`_webagent-verification.
${
domain
.
hostname
}
`
;
try
{
const
records
=
await
dns
.
resolveTxt
(
host
);
const
valid
=
records
.
some
((
chunks
)
=>
chunks
.
join
(
""
)
===
domain
.
verificationToken
);
return
valid
?
{
valid
:
true
}
:
{
valid
:
false
,
error
:
`未找到
${
host
}
的正确 TXT 记录`
};
}
catch
{
return
{
valid
:
false
,
error
:
`暂未查询到
${
host
}
的 TXT 记录`
};
}
}
private
async
checkRouting
(
hostname
:
string
):
Promise
<
{
valid
:
boolean
;
error
?:
string
}
>
{
const
target
=
config
.
domains
.
cnameTarget
;
try
{
const
cnames
=
await
dns
.
resolveCname
(
hostname
).
catch
(()
=>
[]);
if
(
cnames
.
some
((
value
)
=>
value
.
toLowerCase
().
replace
(
/
\.
$/
,
""
)
===
target
))
return
{
valid
:
true
};
const
[
hostV4
,
hostV6
,
targetV4
,
targetV6
]
=
await
Promise
.
all
([
dns
.
resolve4
(
hostname
).
catch
(()
=>
[]),
dns
.
resolve6
(
hostname
).
catch
(()
=>
[]),
dns
.
resolve4
(
target
).
catch
(()
=>
[]),
dns
.
resolve6
(
target
).
catch
(()
=>
[]),
]);
const
accepted
=
new
Set
([
...
targetV4
,
...
targetV6
,
...(
config
.
domains
.
ipv4
?
[
config
.
domains
.
ipv4
]
:
[]),
...(
config
.
domains
.
ipv6
?
[
config
.
domains
.
ipv6
]
:
[]),
]);
if
([...
hostV4
,
...
hostV6
].
some
((
address
)
=>
accepted
.
has
(
address
)))
return
{
valid
:
true
};
return
{
valid
:
false
,
error
:
`域名尚未指向
${
target
}
`
};
}
catch
{
return
{
valid
:
false
,
error
:
`暂未查询到指向
${
target
}
的 DNS 记录`
};
}
}
}
backend/src/schemas.ts
View file @
0c1766d3
...
@@ -23,6 +23,14 @@ export const versionCommitSchema = z.object({
...
@@ -23,6 +23,14 @@ export const versionCommitSchema = z.object({
commit
:
z
.
string
().
regex
(
/^
[
0-9a-f
]{7,40}
$/
),
commit
:
z
.
string
().
regex
(
/^
[
0-9a-f
]{7,40}
$/
),
});
});
export
const
addDomainSchema
=
z
.
object
({
hostname
:
z
.
string
().
trim
().
min
(
4
,
"请输入完整域名"
).
max
(
253
),
});
export
const
updateDomainSchema
=
z
.
object
({
isPrimary
:
z
.
literal
(
true
),
});
export
const
sitePatchSchema
=
z
.
object
({
export
const
sitePatchSchema
=
z
.
object
({
summary
:
z
.
string
().
trim
().
min
(
2
).
max
(
200
),
summary
:
z
.
string
().
trim
().
min
(
2
).
max
(
200
),
operations
:
z
.
array
(
z
.
discriminatedUnion
(
"type"
,
[
operations
:
z
.
array
(
z
.
discriminatedUnion
(
"type"
,
[
...
...
backend/src/server.ts
View file @
0c1766d3
...
@@ -9,11 +9,16 @@ import { BuildManager } from "./build/build-manager.js";
...
@@ -9,11 +9,16 @@ import { BuildManager } from "./build/build-manager.js";
import
{
config
,
runtimePaths
}
from
"./config.js"
;
import
{
config
,
runtimePaths
}
from
"./config.js"
;
import
{
GitManager
}
from
"./git/git-manager.js"
;
import
{
GitManager
}
from
"./git/git-manager.js"
;
import
{
PreviewProcessManager
}
from
"./preview/preview-process-manager.js"
;
import
{
PreviewProcessManager
}
from
"./preview/preview-process-manager.js"
;
import
{
chatSchema
,
createSiteSchema
,
log
inSchema
,
versionCommitSchema
}
from
"./schemas.js"
;
import
{
addDomainSchema
,
chatSchema
,
createSiteSchema
,
loginSchema
,
updateDoma
inSchema
,
versionCommitSchema
}
from
"./schemas.js"
;
import
{
CreateSiteService
}
from
"./sites/create-site.js"
;
import
{
CreateSiteService
}
from
"./sites/create-site.js"
;
import
{
SiteAgentService
}
from
"./sites/site-agent-service.js"
;
import
{
SiteAgentService
}
from
"./sites/site-agent-service.js"
;
import
{
SiteRepository
}
from
"./sites/site-repository.js"
;
import
{
SiteRepository
}
from
"./sites/site-repository.js"
;
import
{
SiteVersionService
}
from
"./sites/site-version-service.js"
;
import
{
SiteVersionService
}
from
"./sites/site-version-service.js"
;
import
{
DomainDeploymentService
}
from
"./domains/domain-deployment-service.js"
;
import
{
DomainRepository
}
from
"./domains/domain-repository.js"
;
import
{
DomainRoutingConfig
}
from
"./domains/domain-routing-config.js"
;
import
{
DomainService
}
from
"./domains/domain-service.js"
;
import
{
createDomainProvider
}
from
"./domains/domain-provider.js"
;
const
app
=
Fastify
({
logger
:
{
level
:
process
.
env
.
LOG_LEVEL
||
"info"
},
bodyLimit
:
1024
*
1024
});
const
app
=
Fastify
({
logger
:
{
level
:
process
.
env
.
LOG_LEVEL
||
"info"
},
bodyLimit
:
1024
*
1024
});
await
app
.
register
(
cors
,
{
origin
:
[
config
.
frontendOrigin
,
"http://127.0.0.1:5173"
]
});
await
app
.
register
(
cors
,
{
origin
:
[
config
.
frontendOrigin
,
"http://127.0.0.1:5173"
]
});
...
@@ -25,6 +30,10 @@ const previews = new PreviewProcessManager();
...
@@ -25,6 +30,10 @@ const previews = new PreviewProcessManager();
const
createSite
=
new
CreateSiteService
(
sites
,
git
,
builds
,
previews
);
const
createSite
=
new
CreateSiteService
(
sites
,
git
,
builds
,
previews
);
const
siteAgent
=
new
SiteAgentService
(
sites
,
git
,
new
AgentLoop
());
const
siteAgent
=
new
SiteAgentService
(
sites
,
git
,
new
AgentLoop
());
const
versions
=
new
SiteVersionService
(
sites
,
git
,
builds
,
previews
);
const
versions
=
new
SiteVersionService
(
sites
,
git
,
builds
,
previews
);
const
domainRepository
=
new
DomainRepository
();
const
domainRouting
=
new
DomainRoutingConfig
(
domainRepository
);
const
domainDeployments
=
new
DomainDeploymentService
(
sites
,
domainRepository
,
git
,
builds
,
domainRouting
);
const
domainService
=
new
DomainService
(
sites
,
domainRepository
,
domainRouting
,
createDomainProvider
());
const
sessions
=
new
Set
<
string
>
();
const
sessions
=
new
Set
<
string
>
();
app
.
addHook
(
"onRequest"
,
async
(
request
,
reply
)
=>
{
app
.
addHook
(
"onRequest"
,
async
(
request
,
reply
)
=>
{
...
@@ -61,13 +70,46 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", a
...
@@ -61,13 +70,46 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", a
const
body
=
versionCommitSchema
.
parse
(
request
.
body
);
return
versions
.
previewVersion
(
request
.
params
.
siteId
,
body
.
commit
);
const
body
=
versionCommitSchema
.
parse
(
request
.
body
);
return
versions
.
previewVersion
(
request
.
params
.
siteId
,
body
.
commit
);
});
});
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/preview-draft"
,
async
(
request
)
=>
versions
.
previewDraft
(
request
.
params
.
siteId
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/preview-draft"
,
async
(
request
)
=>
versions
.
previewDraft
(
request
.
params
.
siteId
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/publish"
,
async
(
request
)
=>
versions
.
publish
(
request
.
params
.
siteId
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/publish"
,
async
(
request
)
=>
{
const
result
=
await
versions
.
publish
(
request
.
params
.
siteId
);
void
domainDeployments
.
schedule
(
request
.
params
.
siteId
,
result
.
commit
).
catch
((
error
)
=>
app
.
log
.
error
(
error
));
return
result
;
});
app
.
get
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/history"
,
async
(
request
)
=>
git
.
history
(
sites
.
getProjectPath
(
request
.
params
.
siteId
)));
app
.
get
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/history"
,
async
(
request
)
=>
git
.
history
(
sites
.
getProjectPath
(
request
.
params
.
siteId
)));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/undo"
,
async
(
request
)
=>
versions
.
undo
(
request
.
params
.
siteId
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/undo"
,
async
(
request
)
=>
versions
.
undo
(
request
.
params
.
siteId
));
app
.
get
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/domains"
,
async
(
request
)
=>
domainService
.
list
(
request
.
params
.
siteId
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/domains"
,
async
(
request
,
reply
)
=>
{
const
body
=
addDomainSchema
.
parse
(
request
.
body
);
return
reply
.
code
(
201
).
send
(
await
domainService
.
add
(
request
.
params
.
siteId
,
body
.
hostname
));
});
app
.
post
<
{
Params
:
{
siteId
:
string
;
domainId
:
string
}
}
>
(
"/api/sites/:siteId/domains/:domainId/verify"
,
async
(
request
)
=>
{
const
domain
=
await
domainService
.
verify
(
request
.
params
.
siteId
,
request
.
params
.
domainId
);
const
site
=
await
sites
.
get
(
request
.
params
.
siteId
);
if
(
domain
.
ownershipStatus
===
"verified"
&&
site
.
publishedCommit
)
{
void
domainDeployments
.
schedule
(
site
.
siteId
,
site
.
publishedCommit
).
catch
((
error
)
=>
app
.
log
.
error
(
error
));
}
return
domain
;
});
app
.
patch
<
{
Params
:
{
siteId
:
string
;
domainId
:
string
}
}
>
(
"/api/sites/:siteId/domains/:domainId"
,
async
(
request
)
=>
{
updateDomainSchema
.
parse
(
request
.
body
);
return
domainService
.
setPrimary
(
request
.
params
.
siteId
,
request
.
params
.
domainId
);
});
app
.
delete
<
{
Params
:
{
siteId
:
string
;
domainId
:
string
}
}
>
(
"/api/sites/:siteId/domains/:domainId"
,
async
(
request
,
reply
)
=>
{
await
domainService
.
remove
(
request
.
params
.
siteId
,
request
.
params
.
domainId
);
return
reply
.
code
(
204
).
send
();
});
app
.
get
<
{
Querystring
:
{
domain
?:
string
;
token
?:
string
}
}
>
(
"/internal/domains/allow"
,
async
(
request
,
reply
)
=>
{
if
(
config
.
domains
.
askToken
&&
request
.
query
.
token
!==
config
.
domains
.
askToken
)
return
reply
.
code
(
403
).
send
();
if
(
!
request
.
query
.
domain
||
!
await
domainService
.
isCertificateAllowed
(
request
.
query
.
domain
))
return
reply
.
code
(
404
).
send
();
return
reply
.
code
(
200
).
send
();
});
app
.
setErrorHandler
((
error
,
_request
,
reply
)
=>
{
app
.
setErrorHandler
((
error
,
_request
,
reply
)
=>
{
const
isMissingFile
=
error
instanceof
Error
&&
"code"
in
error
&&
error
.
code
===
"ENOENT"
;
const
isMissingFile
=
error
instanceof
Error
&&
"code"
in
error
&&
error
.
code
===
"ENOENT"
;
const
status
=
error
instanceof
ZodError
?
400
:
isMissingFile
?
404
:
500
;
const
explicitStatus
=
error
instanceof
Error
&&
"statusCode"
in
error
&&
typeof
error
.
statusCode
===
"number"
?
error
.
statusCode
:
undefined
;
const
status
=
error
instanceof
ZodError
?
400
:
explicitStatus
||
(
isMissingFile
?
404
:
500
);
const
message
=
error
instanceof
ZodError
const
message
=
error
instanceof
ZodError
?
error
.
issues
.
map
((
item
)
=>
item
.
message
).
join
(
";"
)
?
error
.
issues
.
map
((
item
)
=>
item
.
message
).
join
(
";"
)
:
error
instanceof
Error
?
error
.
message
:
"请求处理失败"
;
:
error
instanceof
Error
?
error
.
message
:
"请求处理失败"
;
...
@@ -76,14 +118,15 @@ app.setErrorHandler((error, _request, reply) => {
...
@@ -76,14 +118,15 @@ app.setErrorHandler((error, _request, reply) => {
});
});
await
sites
.
ensureRuntime
();
await
sites
.
ensureRuntime
();
await
domainRouting
.
sync
();
await
rm
(
runtimePaths
.
builds
,
{
recursive
:
true
,
force
:
true
});
await
rm
(
runtimePaths
.
builds
,
{
recursive
:
true
,
force
:
true
});
await
mkdir
(
runtimePaths
.
builds
,
{
recursive
:
true
});
await
mkdir
(
runtimePaths
.
builds
,
{
recursive
:
true
});
for
(
cons
t
site
of
await
sites
.
list
())
{
for
(
le
t
site
of
await
sites
.
list
())
{
await
git
.
pruneWorktrees
(
sites
.
getProjectPath
(
site
.
siteId
));
await
git
.
pruneWorktrees
(
sites
.
getProjectPath
(
site
.
siteId
));
await
builds
.
ensureProductionPlaceholder
(
site
.
siteId
,
site
.
name
);
await
builds
.
ensureProductionPlaceholder
(
site
.
siteId
,
site
.
name
);
if
(
site
.
environmentVersion
!==
3
)
{
if
(
site
.
environmentVersion
!==
3
)
{
await
versions
.
rebuild
(
site
.
siteId
).
catch
((
error
)
=>
app
.
log
.
warn
(
error
));
await
versions
.
rebuild
(
site
.
siteId
).
catch
((
error
)
=>
app
.
log
.
warn
(
error
));
continue
;
site
=
await
sites
.
get
(
site
.
siteId
)
;
}
}
const
previewPath
=
path
.
join
(
runtimePaths
.
previews
,
site
.
siteId
);
const
previewPath
=
path
.
join
(
runtimePaths
.
previews
,
site
.
siteId
);
if
(
await
access
(
previewPath
).
then
(()
=>
true
).
catch
(()
=>
false
))
{
if
(
await
access
(
previewPath
).
then
(()
=>
true
).
catch
(()
=>
false
))
{
...
@@ -95,6 +138,41 @@ for (const site of await sites.list()) {
...
@@ -95,6 +138,41 @@ for (const site of await sites.list()) {
}))
}))
.
catch
((
error
)
=>
app
.
log
.
warn
(
error
));
.
catch
((
error
)
=>
app
.
log
.
warn
(
error
));
}
}
if
(
site
.
publishedCommit
)
{
const
domains
=
await
domainRepository
.
list
(
site
.
siteId
);
if
(
domains
.
some
((
domain
)
=>
domain
.
ownershipStatus
===
"verified"
&&
(
domain
.
deploymentStatus
!==
"active"
||
domain
.
deployedCommit
!==
site
.
publishedCommit
)))
{
void
domainDeployments
.
schedule
(
site
.
siteId
,
site
.
publishedCommit
).
catch
((
error
)
=>
app
.
log
.
error
(
error
));
}
}
}
}
await
app
.
listen
({
host
:
config
.
host
,
port
:
config
.
port
});
await
app
.
listen
({
host
:
config
.
host
,
port
:
config
.
port
});
let
checkingDomains
=
false
;
const
checkPendingDomains
=
async
()
=>
{
if
(
checkingDomains
)
return
;
checkingDomains
=
true
;
try
{
const
domains
=
await
domainRepository
.
listAll
();
for
(
const
stored
of
domains
)
{
const
fullyConnected
=
stored
.
ownershipStatus
===
"verified"
&&
stored
.
dnsStatus
===
"valid"
&&
(
stored
.
tlsStatus
===
"active"
||
stored
.
tlsStatus
===
"managed"
)
&&
stored
.
deploymentStatus
===
"active"
;
if
(
fullyConnected
)
continue
;
const
refreshed
=
await
domainService
.
verify
(
stored
.
siteId
,
stored
.
domainId
).
catch
((
error
)
=>
{
app
.
log
.
warn
(
error
);
return
undefined
;
});
if
(
!
refreshed
||
refreshed
.
ownershipStatus
!==
"verified"
)
continue
;
const
site
=
await
sites
.
get
(
stored
.
siteId
);
if
(
site
.
publishedCommit
&&
refreshed
.
deploymentStatus
!==
"deploying"
&&
(
refreshed
.
deploymentStatus
!==
"active"
||
refreshed
.
deployedCommit
!==
site
.
publishedCommit
))
{
void
domainDeployments
.
schedule
(
site
.
siteId
,
site
.
publishedCommit
).
catch
((
error
)
=>
app
.
log
.
error
(
error
));
}
}
}
finally
{
checkingDomains
=
false
;
}
};
setInterval
(()
=>
void
checkPendingDomains
(),
config
.
domains
.
checkIntervalMs
).
unref
();
backend/src/sites/site-repository.ts
View file @
0c1766d3
...
@@ -78,6 +78,7 @@ export class SiteRepository {
...
@@ -78,6 +78,7 @@ export class SiteRepository {
await
Promise
.
all
([
await
Promise
.
all
([
...
Object
.
values
(
runtimePaths
).
map
((
directory
)
=>
mkdir
(
directory
,
{
recursive
:
true
})),
...
Object
.
values
(
runtimePaths
).
map
((
directory
)
=>
mkdir
(
directory
,
{
recursive
:
true
})),
mkdir
(
config
.
productionDir
,
{
recursive
:
true
}),
mkdir
(
config
.
productionDir
,
{
recursive
:
true
}),
mkdir
(
config
.
customDomainsDir
,
{
recursive
:
true
}),
]);
]);
await
this
.
migrateLegacySites
();
await
this
.
migrateLegacySites
();
}
}
...
...
deploy/nginx/README.md
View file @
0c1766d3
...
@@ -18,6 +18,45 @@ pnpm --filter @webagent/backend start
...
@@ -18,6 +18,45 @@ pnpm --filter @webagent/backend start
-
测试预览:
`http://主机地址/previews/site_xxx/`
-
测试预览:
`http://主机地址/previews/site_xxx/`
-
生产站点:
`http://主机地址/sites/site_xxx/`
-
生产站点:
`http://主机地址/sites/site_xxx/`
## 自定义域名源站
主 Nginx 入口会在请求
`Host`
命中已验证域名映射时直接提供自定义域名产物;未命中时仍走原控制台、API、
`/previews/`
和
`/sites/`
逻辑。因此 Cloudflare/CDN 使用现有公网源站地址即可,但必须保留用户原始
`Host`
。
`webagent.conf`
还额外监听
`127.0.0.1:8080`
,供 Caddy、同机网关等需要独立源站端口的接入方式使用。外部接入层需要:
1.
保留用户请求的原始
`Host`
。
2.
将自定义域名流量转发到服务器的
`127.0.0.1:8080`
。
3.
用户 DNS CNAME 指向
`.env`
中的
`WEBAGENT_DOMAIN_CNAME_TARGET`
。
后端会生成以下映射文件,Nginx reload 后生效:
```
text
.runtime/nginx/domains/routes.map
.runtime/nginx/redirects/routes.map
```
生产环境建议设置
`WEBAGENT_NGINX_AUTO_RELOAD=true`
,后端会先执行
`nginx -t`
,成功后再 reload。未开启时,修改域名配置后可手动执行:
```
bash
nginx
-t
&&
nginx
-s
reload
```
自定义域名内容位于
`WEBAGENT_CUSTOM_DOMAINS_DIR/site_xxx/`
,使用
`SITE_BASE_PATH=/`
单独构建。该流程失败不会影响
`/sites/site_xxx/`
。
### Cloudflare for SaaS
设置以下环境变量后,用户完成 WebAgent TXT 验证时,系统会自动创建 Custom Hostname,并在域名管理页面展示 Cloudflare 返回的 hostname validation 和 certificate validation 记录:
```
bash
WEBAGENT_DOMAIN_HTTPS_MODE
=
cloudflare
CLOUDFLARE_API_TOKEN
=
具备Custom Hostnames编辑权限的Token
CLOUDFLARE_ZONE_ID
=
你的SaaS Zone ID
```
Cloudflare Zone 中仍需提前配置 Fallback Origin 和
`WEBAGENT_DOMAIN_CNAME_TARGET`
对应的 CNAME Target。
待连接域名默认每 60 秒自动刷新一次所有权、证书和 DNS 状态,可通过
`WEBAGENT_DOMAIN_CHECK_INTERVAL_MS`
调整。
Nginx 由 Homebrew service 管理,登录后会自动启动;Fastify 仍需使用 launchd、进程管理器或终端进程保持运行。更换项目目录时,需要修改
`webagent.conf`
中的
`root`
和
`alias`
。
Nginx 由 Homebrew service 管理,登录后会自动启动;Fastify 仍需使用 launchd、进程管理器或终端进程保持运行。更换项目目录时,需要修改
`webagent.conf`
中的
`root`
和
`alias`
。
当前 MVP 没有用户身份、站点权限和请求限流。可以在可信局域网演示;正式暴露到公网前必须补充 HTTPS、鉴权、限流和站点所有权校验。
当前 MVP 没有用户身份、站点权限和请求限流。可以在可信局域网演示;正式暴露到公网前必须补充 HTTPS、鉴权、限流和站点所有权校验。
deploy/nginx/webagent.conf
View file @
0c1766d3
map
$
host
$
webagent_custom_site_id
{
hostnames
;
default
""
;
include
/
Users
/
mac
/
Desktop
/
code
/
WebAgent
/.
runtime
/
nginx
/
domains
/*.
map
;
}
map
$
host
$
webagent_custom_redirect_host
{
hostnames
;
default
""
;
include
/
Users
/
mac
/
Desktop
/
code
/
WebAgent
/.
runtime
/
nginx
/
redirects
/*.
map
;
}
server
{
server
{
listen
80
default_server
;
listen
80
default_server
;
listen
[::]:
80
default_server
;
listen
[::]:
80
default_server
;
...
@@ -12,6 +24,18 @@ server {
...
@@ -12,6 +24,18 @@ server {
gzip
on
;
gzip
on
;
gzip_types
text
/
plain
text
/
css
application
/
json
application
/
javascript
image
/
svg
+
xml
;
gzip_types
text
/
plain
text
/
css
application
/
json
application
/
javascript
image
/
svg
+
xml
;
# Cloudflare/CDN 回源时保留用户 Host。只有命中后端生成映射的域名才进入
# 自定义站点目录;平台域名和未知 Host 继续使用原有控制台入口。
error_page
418
= @
custom_domain_site
;
if
($
webagent_custom_redirect_host
!=
""
) {
return
308
https
://$
webagent_custom_redirect_host
$
request_uri
;
}
if
($
webagent_custom_site_id
!=
""
) {
return
418
;
}
location
/
api
/ {
location
/
api
/ {
proxy_pass
http
://
127
.
0
.
0
.
1
:
3100
;
proxy_pass
http
://
127
.
0
.
0
.
1
:
3100
;
proxy_http_version
1
.
1
;
proxy_http_version
1
.
1
;
...
@@ -39,6 +63,44 @@ server {
...
@@ -39,6 +63,44 @@ server {
try_files
$
uri
$
uri
/ /
index
.
html
;
try_files
$
uri
$
uri
/ /
index
.
html
;
}
}
location
@
custom_domain_site
{
root
/
Users
/
mac
/
Desktop
/
code
/
WebAgent
-
custom
-
domains
/$
webagent_custom_site_id
;
try_files
$
uri
$
uri
/ $
uri
/
index
.
html
=
404
;
add_header
Cache
-
Control
"no-cache"
always
;
}
location
~ /\. {
deny
all
;
}
}
# 自定义域名专用源站入口。公网 HTTPS/CDN 只需要把用户域名流量转发到此端口,
# 不会修改上面的控制台、API、/previews/ 和 /sites/ 域名体系。
server
{
listen
127
.
0
.
0
.
1
:
8080
;
server_name
_
;
charset
utf
-
8
;
root
/
Users
/
mac
/
Desktop
/
code
/
WebAgent
-
custom
-
domains
/$
webagent_custom_site_id
;
index
index
.
html
;
if
($
webagent_custom_site_id
=
""
) {
return
421
;
}
if
($
webagent_custom_redirect_host
!=
""
) {
return
308
https
://$
webagent_custom_redirect_host
$
request_uri
;
}
location
/
api
/ {
return
404
;
}
location
/ {
try_files
$
uri
$
uri
/ $
uri
/
index
.
html
=
404
;
add_header
Cache
-
Control
"no-cache"
always
;
}
location
~ /\. {
location
~ /\. {
deny
all
;
deny
all
;
}
}
...
...
docs/storage-convention.md
View file @
0c1766d3
...
@@ -13,6 +13,9 @@ WebAgent-sites/
...
@@ -13,6 +13,9 @@ WebAgent-sites/
WebAgent-production/
WebAgent-production/
└── site_xxxxxx/ # 用户确认发布的生产静态产物
└── site_xxxxxx/ # 用户确认发布的生产静态产物
WebAgent-custom-domains/
└── site_xxxxxx/ # 自定义域名根路径产物,使用 SITE_BASE_PATH=/ 构建
WebAgent/.runtime/
WebAgent/.runtime/
├── builds/
├── builds/
│ └── task_xxxxxx/
│ └── task_xxxxxx/
...
@@ -21,10 +24,11 @@ WebAgent/.runtime/
...
@@ -21,10 +24,11 @@ WebAgent/.runtime/
│ └── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物
│ └── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物
├── uploads/ # 原始上传文件的临时落点
├── uploads/ # 原始上传文件的临时落点
├── pnpm-store/ # 所有生成站点共享的 pnpm store
├── pnpm-store/ # 所有生成站点共享的 pnpm store
└── logs/ # Agent 与构建日志
├── logs/ # Agent 与构建日志
└── nginx/ # 根据已验证域名生成的 Host 路由映射
```
```
`WebAgent-sites/`
和
`WebAgent-production
/`
是持久数据,应纳入服务器备份;
`.runtime/`
是可重建的测试与运行缓存,不提交 Git。修改路径配置后,系统不会自动迁移外部目录之间的数据,需要由管理员手动移动。旧版本位于
`.runtime/sites/`
的站点会在首次启动时自动迁移到当前配置目录。
`WebAgent-sites/`
、
`WebAgent-production/`
和
`WebAgent-custom-domains
/`
是持久数据,应纳入服务器备份;
`.runtime/`
是可重建的测试与运行缓存,不提交 Git。修改路径配置后,系统不会自动迁移外部目录之间的数据,需要由管理员手动移动。旧版本位于
`.runtime/sites/`
的站点会在首次启动时自动迁移到当前配置目录。
## 不变量
## 不变量
...
@@ -35,3 +39,5 @@ WebAgent/.runtime/
...
@@ -35,3 +39,5 @@ WebAgent/.runtime/
5.
`metadata/site.json`
由 WebAgent 独占写入,站点代码不能反向引用它。
5.
`metadata/site.json`
由 WebAgent 独占写入,站点代码不能反向引用它。
6.
删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
6.
删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
7.
每个站点拥有稳定的
`siteId`
和预览端口;站点名称不参与路径计算。
7.
每个站点拥有稳定的
`siteId`
和预览端口;站点名称不参与路径计算。
8.
自定义域名必须通过 TXT 所有权验证后才允许生成路由和申请证书。
9.
自定义域名构建或 DNS 故障不能覆盖、阻塞或改变
`/sites/site_xxx/`
生产环境。
frontend/src/App.tsx
View file @
0c1766d3
This diff is collapsed.
Click to expand it.
frontend/src/api.ts
View file @
0c1766d3
import
type
{
ChatResult
,
CreateSiteInput
,
GitHistoryItem
,
PreviewVersionResult
,
PublishResult
,
SiteInfo
}
from
"@webagent/shared"
;
import
type
{
ChatResult
,
CreateSiteInput
,
DomainBinding
,
DomainListResult
,
GitHistoryItem
,
PreviewVersionResult
,
PublishResult
,
SiteInfo
}
from
"@webagent/shared"
;
async
function
request
<
T
>
(
url
:
string
,
options
?:
RequestInit
):
Promise
<
T
>
{
async
function
request
<
T
>
(
url
:
string
,
options
?:
RequestInit
):
Promise
<
T
>
{
const
headers
=
new
Headers
(
options
?.
headers
);
const
headers
=
new
Headers
(
options
?.
headers
);
...
@@ -29,4 +29,9 @@ export const api = {
...
@@ -29,4 +29,9 @@ export const api = {
rebuild
:
(
siteId
:
string
)
=>
request
<
{
previewUrl
:
string
}
>
(
"/api/sites/"
+
siteId
+
"/build"
,
{
method
:
"POST"
}),
rebuild
:
(
siteId
:
string
)
=>
request
<
{
previewUrl
:
string
}
>
(
"/api/sites/"
+
siteId
+
"/build"
,
{
method
:
"POST"
}),
publish
:
(
siteId
:
string
)
=>
request
<
PublishResult
>
(
"/api/sites/"
+
siteId
+
"/publish"
,
{
method
:
"POST"
}),
publish
:
(
siteId
:
string
)
=>
request
<
PublishResult
>
(
"/api/sites/"
+
siteId
+
"/publish"
,
{
method
:
"POST"
}),
undo
:
(
siteId
:
string
)
=>
request
<
PreviewVersionResult
>
(
"/api/sites/"
+
siteId
+
"/undo"
,
{
method
:
"POST"
}),
undo
:
(
siteId
:
string
)
=>
request
<
PreviewVersionResult
>
(
"/api/sites/"
+
siteId
+
"/undo"
,
{
method
:
"POST"
}),
domains
:
(
siteId
:
string
)
=>
request
<
DomainListResult
>
(
"/api/sites/"
+
siteId
+
"/domains"
),
addDomain
:
(
siteId
:
string
,
hostname
:
string
)
=>
request
<
DomainBinding
>
(
"/api/sites/"
+
siteId
+
"/domains"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
hostname
})
}),
verifyDomain
:
(
siteId
:
string
,
domainId
:
string
)
=>
request
<
DomainBinding
>
(
"/api/sites/"
+
siteId
+
"/domains/"
+
domainId
+
"/verify"
,
{
method
:
"POST"
}),
setPrimaryDomain
:
(
siteId
:
string
,
domainId
:
string
)
=>
request
<
DomainBinding
>
(
"/api/sites/"
+
siteId
+
"/domains/"
+
domainId
,
{
method
:
"PATCH"
,
body
:
JSON
.
stringify
({
isPrimary
:
true
})
}),
removeDomain
:
(
siteId
:
string
,
domainId
:
string
)
=>
request
<
void
>
(
"/api/sites/"
+
siteId
+
"/domains/"
+
domainId
,
{
method
:
"DELETE"
}),
};
};
frontend/src/styles.css
View file @
0c1766d3
This diff is collapsed.
Click to expand it.
shared/src/index.ts
View file @
0c1766d3
...
@@ -72,3 +72,52 @@ export interface PreviewVersionResult {
...
@@ -72,3 +72,52 @@ export interface PreviewVersionResult {
previewUrl
:
string
;
previewUrl
:
string
;
commit
:
string
;
commit
:
string
;
}
}
export
type
DomainOwnershipStatus
=
"pending"
|
"verified"
|
"failed"
;
export
type
DomainDnsStatus
=
"pending"
|
"valid"
|
"invalid"
;
export
type
DomainDeploymentStatus
=
"pending"
|
"deploying"
|
"active"
|
"failed"
;
export
type
DomainTlsStatus
=
"pending"
|
"active"
|
"managed"
|
"failed"
;
export
type
DomainStatus
=
"pending_verification"
|
"deploying"
|
"provisioning_tls"
|
"pending_dns"
|
"active"
|
"failed"
;
export
interface
DomainDnsRecord
{
type
:
"TXT"
|
"CNAME"
;
name
:
string
;
value
:
string
;
purpose
:
"hostname"
|
"certificate"
;
}
export
interface
DomainBinding
{
domainId
:
string
;
siteId
:
string
;
hostname
:
string
;
isPrimary
:
boolean
;
ownershipStatus
:
DomainOwnershipStatus
;
dnsStatus
:
DomainDnsStatus
;
deploymentStatus
:
DomainDeploymentStatus
;
tlsStatus
:
DomainTlsStatus
;
status
:
DomainStatus
;
verificationHost
:
string
;
verificationValue
:
string
;
cnameTarget
:
string
;
url
:
string
;
deployedCommit
?:
string
;
providerHostnameId
?:
string
;
providerStatus
?:
string
;
certificateRecords
?:
DomainDnsRecord
[];
lastCheckedAt
?:
string
;
lastError
?:
string
;
lastDnsError
?:
string
;
lastTlsError
?:
string
;
lastDeploymentError
?:
string
;
createdAt
:
string
;
updatedAt
:
string
;
}
export
interface
DomainListResult
{
domains
:
DomainBinding
[];
cnameTarget
:
string
;
configured
:
boolean
;
ipv4
?:
string
;
ipv6
?:
string
;
httpsMode
:
"external"
|
"caddy"
|
"cloudflare"
;
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment