[优化] go 引用调整

This commit is contained in:
ryan
2026-06-06 10:26:20 +08:00
parent ee1110b752
commit 3cfefb4367
552 changed files with 1642 additions and 2185 deletions
@@ -0,0 +1,50 @@
import {apiRequest} from '@/lib/api/client';
import type {
ManagedDomainMatchResult,
ProxyRouteItem,
ProxyRouteMutationPayload,
TlsCertificateItem,
} from '@/features/proxy-routes/types';
export function getProxyRoutes() {
return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
}
export function getProxyRoute(id: number) {
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}`);
}
export function createProxyRoute(payload: ProxyRouteMutationPayload) {
return apiRequest<ProxyRouteItem>('/proxy-routes/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateProxyRoute(
id: number,
payload: ProxyRouteMutationPayload,
) {
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteProxyRoute(id: number) {
return apiRequest<void>(`/proxy-routes/${id}/delete`, {
method: 'POST',
});
}
export function getTlsCertificates() {
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
}
export function matchManagedDomainCertificate(domain: string) {
const searchParams = new URLSearchParams({ domain });
return apiRequest<ManagedDomainMatchResult>(
`/managed-domains/match?${searchParams.toString()}`,
);
}
@@ -0,0 +1,265 @@
'use client';
import {useId, useMemo} from 'react';
import {Minus, Plus} from 'lucide-react';
import type {TlsCertificateItem} from '@/features/tls-certificates/types';
import {ResourceInput, ResourceSelect, SecondaryButton,} from '@/features/shared/components/resource-primitives';
export type DomainListRow = {
domain: string;
certificateId: string;
};
const actionButtonBaseClassName = 'h-11 w-11 shrink-0 rounded-2xl px-0';
const removeButtonClassName =
'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:border-[var(--status-danger-border)] hover:bg-[var(--status-danger-soft)] hover:text-[var(--status-danger-foreground)] disabled:border-[var(--border-default)] disabled:bg-[var(--surface-muted)] disabled:text-[var(--foreground-muted)]';
const addButtonClassName =
'border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-[var(--foreground-secondary)] hover:border-[var(--brand-primary)] hover:bg-[var(--brand-primary-soft)] hover:text-[var(--brand-primary)]';
function ensureRows(rows: DomainListRow[]) {
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
}
function buildSuggestionSources(domains: string[]) {
const values = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
if (!normalized) {
continue;
}
values.add(normalized);
const segments = normalized.split('.');
for (let index = 1; index < segments.length - 1; index += 1) {
values.add(segments.slice(index).join('.'));
}
}
return Array.from(values);
}
function buildDomainSuggestions(
input: string,
sources: string[],
rows: DomainListRow[],
) {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return [];
}
const existingDomains = new Set(
rows
.map((row) => row.domain.trim().toLowerCase())
.filter((row) => row && row !== normalizedInput),
);
const suggestions: string[] = [];
for (const source of sources) {
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
suggestions.push(source);
}
const separatorIndex = normalizedInput.lastIndexOf('.');
if (separatorIndex <= 0) {
continue;
}
const prefix = normalizedInput.slice(0, separatorIndex);
const suffixInput = normalizedInput.slice(separatorIndex + 1);
if (!suffixInput || source.startsWith(suffixInput)) {
suggestions.push(`${prefix}.${source}`);
}
}
return suggestions.filter((suggestion, index) => {
return (
suggestion !== normalizedInput &&
!existingDomains.has(suggestion) &&
suggestions.indexOf(suggestion) === index
);
});
}
export function buildDomainRowsFromRoute(
domains: string[],
domainCertIDs: number[],
certIDs: number[],
): DomainListRow[] {
if (domains.length === 0) {
return ensureRows([]);
}
if (domainCertIDs.length === domains.length) {
return domains.map((domain, index) => ({
domain,
certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
}));
}
if (certIDs.length === 0) {
return domains.map((domain) => ({ domain, certificateId: '' }));
}
if (certIDs.length === 1) {
return domains.map((domain) => ({
domain,
certificateId: String(certIDs[0]),
}));
}
return domains.map((domain, index) => ({
domain,
certificateId: certIDs[index] ? String(certIDs[index]) : '',
}));
}
export function DomainListInput({
rows,
onChange,
onBlur,
suggestionSources = [],
certificates = [],
domainPlaceholder = 'app.example.com',
}: {
rows: DomainListRow[];
onChange: (rows: DomainListRow[]) => void;
onBlur?: () => void;
suggestionSources?: string[];
certificates?: TlsCertificateItem[];
domainPlaceholder?: string;
}) {
const listId = useId();
const safeRows = ensureRows(rows);
const normalizedSources = useMemo(
() => buildSuggestionSources(suggestionSources),
[suggestionSources],
);
const updateRows = (nextRows: DomainListRow[]) => {
onChange(ensureRows(nextRows));
};
return (
<div className="space-y-4">
{safeRows.map((row, index) => {
const suggestions = buildDomainSuggestions(
row.domain,
normalizedSources,
safeRows,
).slice(0, 4);
return (
<div key={`${index}-${safeRows.length}`} className="space-y-2">
<div className="grid gap-3 md:grid-cols-[44px_minmax(0,1fr)_280px] md:items-start">
<SecondaryButton
type="button"
aria-label={`删除域名输入框 ${index + 1}`}
className={`${actionButtonBaseClassName} ${removeButtonClassName}`}
disabled={safeRows.length === 1}
onClick={() => {
if (safeRows.length === 1) {
updateRows([{ domain: '', certificateId: '' }]);
return;
}
updateRows(
safeRows.filter((_, rowIndex) => rowIndex !== index),
);
}}
>
<Minus aria-hidden="true" className="h-[14px] w-[14px]" />
</SecondaryButton>
<div className="min-w-0 space-y-2">
<ResourceInput
value={row.domain}
list={`${listId}-${index}`}
aria-label={`域名 ${index + 1}`}
placeholder={
index === 0 ? domainPlaceholder : 'www.example.com'
}
onBlur={onBlur}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: event.target.value,
};
updateRows(nextRows);
}}
className="h-12"
/>
<datalist id={`${listId}-${index}`}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="inline-flex items-center rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-1 text-xs text-[var(--foreground-secondary)] transition hover:border-[var(--border-strong)] hover:text-[var(--foreground-primary)]"
onClick={() => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: suggestion,
};
updateRows(nextRows);
}}
>
{suggestion}
</button>
))}
</div>
) : null}
</div>
<ResourceSelect
aria-label={`证书 ${index + 1}`}
value={row.certificateId}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
certificateId: event.target.value,
};
updateRows(nextRows);
}}
className="h-12"
>
<option value="">
{certificates.length === 0 ? '暂无可选证书' : '选择证书'}
</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{certificate.name}
</option>
))}
</ResourceSelect>
</div>
</div>
);
})}
<SecondaryButton
type="button"
aria-label="新增域名输入框"
className={`${actionButtonBaseClassName} ${addButtonClassName}`}
onClick={() => {
updateRows([...safeRows, { domain: '', certificateId: '' }]);
}}
>
<Plus aria-hidden="true" className="h-[14px] w-[14px]" />
</SecondaryButton>
</div>
);
}
@@ -0,0 +1,523 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQuery} from '@tanstack/react-query';
import {useEffect, useMemo} from 'react';
import {Controller, useForm} from 'react-hook-form';
import {z} from 'zod';
import {Drawer} from '@/components/ui/drawer';
import {getManagedDomains} from '@/features/managed-domains/api/managed-domains';
import {getPagesProjects} from '@/features/pages/api/pages';
import {createProxyRoute} from '@/features/proxy-routes/api/proxy-routes';
import {DomainListInput, type DomainListRow,} from '@/features/proxy-routes/components/domain-list-input';
import {
buildOriginUrl,
getErrorMessage,
parseOriginUrl,
parseOriginUrls,
validateDomains,
} from '@/features/proxy-routes/helpers';
import {getNodes} from '@/features/nodes/api/nodes';
import type {ProxyRouteItem} from '@/features/proxy-routes/types';
import {getTlsCertificates} from '@/features/tls-certificates/api/tls-certificates';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceTextarea,
ToggleField,
} from '@/features/shared/components/resource-primitives';
const domainRowSchema = z.object({
domain: z.string(),
certificateId: z.string(),
});
const createWebsiteSchema = z
.object({
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
domain_rows: z.array(domainRowSchema).min(1),
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
origin_urls_text: z.string().trim(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
enabled: z.boolean(),
redirect_http: z.boolean(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.superRefine((value, context) => {
const domains = value.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainError = validateDomains(domains);
if (domainError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['domain_rows'],
message: domainError,
});
}
if (value.upstream_type === 'direct') {
if (!value.origin_urls_text.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: '请至少填写一个上游地址',
});
} else {
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
}
} else if (value.upstream_type === 'tunnel') {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_id'],
message: '请选择内网穿透隧道',
});
}
if (!value.tunnel_target_addr) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_target_addr'],
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
} else if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pages_project_id'],
message: '请选择 Pages 项目',
});
}
const selectedCertificateCount = new Set(
value.domain_rows
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
).size;
if (value.redirect_http && selectedCertificateCount === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['redirect_http'],
message: '启用 HTTP 跳转前,请先为域名选择证书',
});
}
});
type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
const defaultValues: CreateWebsiteFormValues = {
site_name: '',
domain_rows: [{ domain: '', certificateId: '' }],
upstream_type: 'direct',
origin_urls_text: '',
tunnel_id: '',
tunnel_target_addr: '',
tunnel_target_protocol: 'http',
pages_project_id: '',
enabled: true,
redirect_http: false,
remark: '',
};
function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
return Array.from(
new Set(
rows
.filter((item) => item.domain.trim() !== '')
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
),
);
}
function buildDomainCertificateIDs(rows: DomainListRow[]) {
return rows
.filter((item) => item.domain.trim() !== '')
.map((item) => {
const certificateID = Number(item.certificateId);
return Number.isFinite(certificateID) && certificateID > 0
? certificateID
: 0;
});
}
export function ProxyRouteCreateDrawer({
open,
onOpenChange,
onCreated,
domainSuggestionSources = [],
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (route: ProxyRouteItem) => void;
domainSuggestionSources?: string[];
}) {
const form = useForm<CreateWebsiteFormValues>({
resolver: zodResolver(createWebsiteSchema),
defaultValues,
});
const managedDomainsQuery = useQuery({
queryKey: ['managed-domains'],
queryFn: getManagedDomains,
enabled: open,
});
const certificatesQuery = useQuery({
queryKey: ['tls-certificates', 'list'],
queryFn: getTlsCertificates,
enabled: open,
});
const tunnelsQuery = useQuery({
queryKey: ['nodes'],
queryFn: getNodes,
enabled: open,
});
const pagesProjectsQuery = useQuery({
queryKey: ['pages-projects'],
queryFn: getPagesProjects,
enabled: open,
});
const tunnelClients = (tunnelsQuery.data ?? []).filter(
(node) => node.node_type === 'tunnel_client',
);
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
(project) => project.enabled && project.active_deployment_id,
);
const combinedDomainSuggestions = useMemo(
() => [
...domainSuggestionSources,
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
],
[domainSuggestionSources, managedDomainsQuery.data],
);
const selectedCertificateIDs = normalizeSelectedCertificateIDs(
form.watch('domain_rows'),
);
const createMutation = useMutation({
mutationFn: async (values: CreateWebsiteFormValues) => {
const domains = values.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
const selectedCertIDs = normalizeSelectedCertificateIDs(
values.domain_rows,
);
let originUrl = '';
const originHost = '';
let upstreams: string[] = [];
if (values.upstream_type === 'direct') {
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
originUrl = buildOriginUrl(
primaryOrigin.scheme,
primaryOrigin.address,
primaryOrigin.port,
primaryOrigin.uri,
);
upstreams = urls.slice(1);
} else if (values.upstream_type === 'tunnel') {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
} else {
originUrl = 'http://127.0.0.1';
}
return createProxyRoute({
site_name: values.site_name.trim() || domains[0],
domain: domains[0],
domains,
origin_id: null,
origin_url: originUrl,
origin_scheme:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).scheme
: 'http',
origin_address:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).address
: values.tunnel_target_addr || '127.0.0.1',
origin_port:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).port
: '80',
origin_uri:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).uri
: '',
origin_host: originHost,
upstreams,
enabled: values.enabled,
enable_https: selectedCertIDs.length > 0,
cert_id: selectedCertIDs.length > 0 ? selectedCertIDs[0] : null,
cert_ids: selectedCertIDs,
domain_cert_ids: domainCertIDs,
redirect_http:
selectedCertIDs.length > 0 ? values.redirect_http : false,
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
cache_enabled: false,
cache_policy: 'url',
cache_rules: [],
custom_headers: [],
pow_enabled: false,
pow_config: '{}',
basic_auth_enabled: false,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_node_id:
values.upstream_type === 'tunnel' && values.tunnel_id
? Number(values.tunnel_id)
: null,
tunnel_target_addr:
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol:
values.upstream_type === 'tunnel'
? values.tunnel_target_protocol
: '',
pages_project_id:
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
: null,
});
},
onSuccess: (route) => {
form.reset(defaultValues);
onOpenChange(false);
onCreated(route);
},
});
useEffect(() => {
if (!open) {
form.reset(defaultValues);
}
}, [form, open]);
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
direction="right"
title="新建规则"
footer={
<div className="flex items-center justify-end gap-3">
<PrimaryButton
type="submit"
form="create-website-form"
disabled={createMutation.isPending}
>
{createMutation.isPending ? '创建中...' : '创建'}
</PrimaryButton>
</div>
}
>
<form
id="create-website-form"
className="space-y-5"
onSubmit={form.handleSubmit((values) => createMutation.mutate(values))}
>
<ResourceField
label="站点标识"
hint="可选,留空时会自动使用第一个域名。"
error={form.formState.errors.site_name?.message}
>
<ResourceInput
{...form.register('site_name')}
placeholder="marketing-site"
/>
</ResourceField>
<ResourceField
label="域名列表"
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
error={
form.formState.errors.domain_rows?.message as string | undefined
}
container="div"
>
<Controller
control={form.control}
name="domain_rows"
render={({ field }) => (
<DomainListInput
rows={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={combinedDomainSuggestions}
certificates={certificatesQuery.data ?? []}
/>
)}
/>
</ResourceField>
<ToggleField
label="HTTP 自动跳转到 HTTPS"
description={
selectedCertificateIDs.length > 0
? '勾选后会额外生成 80 端口重定向规则。'
: '至少为一个域名选择证书后才能启用。'
}
checked={form.watch('redirect_http')}
disabled={selectedCertificateIDs.length === 0}
onChange={(checked) =>
form.setValue('redirect_http', checked, { shouldDirty: true })
}
/>
<div className="space-y-3">
<label className="block text-sm font-medium text-[var(--foreground-primary)]">
回源方式
</label>
<div className="flex gap-4">
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
<input
type="radio"
value="direct"
{...form.register('upstream_type')}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
直连上游
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
<input
type="radio"
value="tunnel"
{...form.register('upstream_type')}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
内网穿透 (Tunnel)
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
<input
type="radio"
value="pages"
{...form.register('upstream_type')}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Pages 静态站点
</label>
</div>
</div>
{form.watch('upstream_type') === 'direct' ? (
<ResourceField
label="上游地址"
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
aria-label="上游地址"
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...form.register('origin_urls_text')}
/>
</ResourceField>
) : form.watch('upstream_type') === 'tunnel' ? (
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
<ResourceField
label="选择内网穿透隧道"
hint="将请求转发到该隧道连接的客户端节点。"
error={form.formState.errors.tunnel_id?.message}
>
<select
{...form.register('tunnel_id')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{tunnelClients.map((tunnel) => (
<option key={tunnel.id} value={tunnel.id}>
{tunnel.name} (
{tunnel.status === 'online' ? '在线' : '离线'})
</option>
))}
</select>
</ResourceField>
<ResourceField
label="内网服务协议"
hint="转发到内网服务时使用的协议。"
error={form.formState.errors.tunnel_target_protocol?.message}
>
<select
{...form.register('tunnel_target_protocol')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</ResourceField>
<ResourceField
label="内网服务地址"
hint="例如: 127.0.0.1:8080 或 192.168.1.10:80"
error={form.formState.errors.tunnel_target_addr?.message}
>
<ResourceInput
placeholder="127.0.0.1:8080"
{...form.register('tunnel_target_addr')}
/>
</ResourceField>
</div>
) : (
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
<ResourceField
label="选择 Pages 项目"
hint="仅显示已启用且已有激活部署的 Pages 项目。"
error={form.formState.errors.pages_project_id?.message}
>
<select
{...form.register('pages_project_id')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{pagesProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name} ({project.slug})
</option>
))}
</select>
</ResourceField>
</div>
)}
<ToggleField
label="创建后立即启用"
description="关闭后站点会以草稿保存,后续仍可继续编辑。"
checked={form.watch('enabled')}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
/>
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea {...form.register('remark')} />
</ResourceField>
{createMutation.isError ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
{getErrorMessage(createMutation.error)}
</p>
) : null}
</form>
</Drawer>
);
}
@@ -0,0 +1,404 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card';
import {StatusBadge} from '@/components/ui/status-badge';
import {getConfigVersionDiff, publishConfigVersion,} from '@/features/config-versions/api/config-versions';
import {ProxyRouteCreateDrawer} from '@/features/proxy-routes/components/proxy-route-create-drawer';
import {getErrorMessage, getUpstreamSummary, getWebsiteStatusBadges,} from '@/features/proxy-routes/helpers';
import {deleteProxyRoute, getProxyRoutes,} from '@/features/proxy-routes/api/proxy-routes';
import type {ProxyRouteItem} from '@/features/proxy-routes/types';
import {
DangerButton,
PrimaryButton,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
function hasConfigChanges(diff: {
active_version?: string;
added_sites: string[];
removed_sites: string[];
modified_sites: string[];
added_domains: string[];
removed_domains: string[];
modified_domains: string[];
main_config_changed: boolean;
waf_config_changed?: boolean;
changed_option_keys: string[];
}) {
return (
diff.added_sites.length > 0 ||
diff.removed_sites.length > 0 ||
diff.modified_sites.length > 0 ||
diff.added_domains.length > 0 ||
diff.removed_domains.length > 0 ||
diff.modified_domains.length > 0 ||
diff.main_config_changed ||
Boolean(diff.waf_config_changed) ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
);
}
export function ProxyRoutesPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [keyword, setKeyword] = useState('');
const [isCreateOpen, setIsCreateOpen] = useState(false);
const routesQuery = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
});
const diffQuery = useQuery({
queryKey: ['config-versions', 'diff'],
queryFn: getConfigVersionDiff,
});
const deleteMutation = useMutation({
mutationFn: deleteProxyRoute,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '网站已删除。' });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const publishMutation = useMutation({
mutationFn: publishConfigVersion,
onSuccess: async (version) => {
setFeedback({
tone: 'success',
message: `配置已发布,版本号 ${version.version}。`,
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
const filteredRoutes = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) {
return routes;
}
return routes.filter((route) => {
const haystack = [
route.site_name,
route.primary_domain,
...route.domains,
...route.upstream_list,
route.remark,
]
.join(' ')
.toLowerCase();
return haystack.includes(normalizedKeyword);
});
}, [keyword, routes]);
const diff = diffQuery.data;
const totalDomains = routes.reduce(
(sum, route) => sum + route.domain_count,
0,
);
const enabledCount = routes.filter((route) => route.enabled).length;
const handleDelete = (route: ProxyRouteItem) => {
if (
!window.confirm(
`确认删除网站 ${route.site_name} 吗?\n这会删除该站点下的全部域名与配置。`,
)
) {
return;
}
setFeedback(null);
deleteMutation.mutate(route.id);
};
const handlePublish = () => {
if (!diff || !hasConfigChanges(diff)) {
setFeedback({ tone: 'info', message: '当前草稿没有可发布的变更。' });
return;
}
const summary = [
`新增网站 ${diff.added_sites.length} 个`,
`删除网站 ${diff.removed_sites.length} 个`,
`修改网站 ${diff.modified_sites.length} 个`,
`域名变更 ${diff.added_domains.length + diff.removed_domains.length + diff.modified_domains.length} 项`,
].join(',');
if (!window.confirm(`确认发布当前配置吗?\n${summary}`)) {
return;
}
setFeedback(null);
publishMutation.mutate(false);
};
if (routesQuery.isLoading) {
return <LoadingState />;
}
if (routesQuery.isError) {
return (
<ErrorState
title="规则列表加载失败"
description={getErrorMessage(routesQuery.error)}
/>
);
}
return (
<>
<div className="space-y-6">
<PageHeader
title="规则配置"
action={
<div className="flex flex-wrap gap-3">
<SecondaryButton
type="button"
onClick={handlePublish}
disabled={publishMutation.isPending || diffQuery.isLoading}
>
{publishMutation.isPending ? '发布中...' : '发布配置'}
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setFeedback(null);
setIsCreateOpen(true);
}}
>
新建规则
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<div className="grid gap-4 xl:grid-cols-4">
<AppCard title="网站数">
<div className="space-y-2">
<p className="text-3xl font-semibold text-[var(--foreground-primary)]">
{routes.length}
</p>
<p className="text-sm text-[var(--foreground-secondary)]">
已启用 {enabledCount} 个
</p>
</div>
</AppCard>
<AppCard title="域名数">
<div className="space-y-2">
<p className="text-3xl font-semibold text-[var(--foreground-primary)]">
{totalDomains}
</p>
<p className="text-sm text-[var(--foreground-secondary)]">
每个网站第一行域名为主域名
</p>
</div>
</AppCard>
<AppCard title="草稿状态">
<div className="space-y-3">
<StatusBadge
label={
diff && hasConfigChanges(diff)
? '有待发布变更'
: '已与线上一致'
}
variant={diff && hasConfigChanges(diff) ? 'warning' : 'success'}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{diff
? `网站变更 ${diff.added_sites.length + diff.removed_sites.length + diff.modified_sites.length} 项`
: '正在读取 diff...'}
</p>
</div>
</AppCard>
<AppCard title="发布摘要">
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>新增网站:{diff?.added_sites.length ?? 0}</p>
<p>删除网站:{diff?.removed_sites.length ?? 0}</p>
<p>修改网站:{diff?.modified_sites.length ?? 0}</p>
</div>
</AppCard>
</div>
<AppCard title="规则列表">
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="max-w-xl">
<ResourceInput
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索站点"
/>
</div>
{diff && hasConfigChanges(diff) ? (
<div className="flex flex-wrap gap-2">
{diff.modified_sites.length > 0 ? (
<StatusBadge
label={`修改网站 ${diff.modified_sites.length}`}
variant="warning"
/>
) : null}
{diff.added_sites.length > 0 ? (
<StatusBadge
label={`新增网站 ${diff.added_sites.length}`}
variant="success"
/>
) : null}
{diff.removed_sites.length > 0 ? (
<StatusBadge
label={`删除网站 ${diff.removed_sites.length}`}
variant="danger"
/>
) : null}
</div>
) : null}
</div>
{filteredRoutes.length === 0 ? (
<EmptyState
title={routes.length === 0 ? '暂无网站' : '没有匹配结果'}
description={
routes.length === 0
? '先创建一个网站,再进入配置子页面继续补齐 HTTPS、缓存和限流。'
: '试试调整搜索词,或者清空筛选条件。'
}
/>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{filteredRoutes.map((route) => (
<article
key={route.id}
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<div className="flex flex-col gap-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-3">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
{route.site_name}
</h2>
{getWebsiteStatusBadges(route).map((badge) => (
<StatusBadge
key={`${route.id}-${badge.label}`}
label={badge.label}
variant={badge.variant}
/>
))}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={`/proxy-route/detail?id=${route.id}&section=domains`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
配置
</Link>
<DangerButton
type="button"
onClick={() => handleDelete(route)}
disabled={deleteMutation.isPending}
>
删除
</DangerButton>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
域名列表
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{route.domains.join(' / ')}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
上游摘要
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{getUpstreamSummary(route)}
</p>
</div>
</div>
<p className="text-sm text-[var(--foreground-secondary)]">
{route.remark || null}
</p>
</div>
</article>
))}
</div>
)}
</div>
</AppCard>
</div>
<ProxyRouteCreateDrawer
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
domainSuggestionSources={routes.flatMap((route) => route.domains)}
onCreated={async (route) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]);
router.push(`/proxy-route/detail?id=${route.id}&section=domains`);
}}
/>
</>
);
}
@@ -0,0 +1,337 @@
import type {ProxyRouteCustomHeader, ProxyRouteItem, ProxyRouteMutationPayload,} from '@/features/proxy-routes/types';
export const websiteConfigSections = [
{
key: 'domains',
label: '域名设置',
description: '维护站点标识、域名列表和证书绑定。',
},
{
key: 'limits',
label: '流量限制',
description: '设置连接数和限速。',
},
{
key: 'proxy',
label: '反向代理',
description: '配置主回源和上游地址。',
},
{
key: 'cache',
label: '缓存',
description: '配置站点缓存策略。',
},
{
key: 'waf',
label: 'WAF',
description: '绑定 WAF 规则组,并查看当前站点生效策略。',
},
{
key: 'auth',
label: '认证配置',
description: '配置基础鉴权访问,需要输入账号密码才能访问网站。',
},
] as const;
export type WebsiteConfigSectionKey =
(typeof websiteConfigSections)[number]['key'];
const domainPattern =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
const originHostPattern =
/^(?:(?:[a-z0-9-]+\.)*[a-z0-9-]+|\[[0-9a-f:.]+\]|[0-9.]+)(?::\d{1,5})?$/i;
const headerKeyPattern = /^[A-Za-z0-9_-]+$/;
const limitRatePattern = /^\d+(?:[kKmM])?$/;
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function getWebsiteConfigSection(
value: string | null | undefined,
): WebsiteConfigSectionKey {
return websiteConfigSections.some((section) => section.key === value)
? (value as WebsiteConfigSectionKey)
: 'domains';
}
export function linesFromTextarea(value: string) {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function validateDomains(domains: string[]) {
if (domains.length === 0) {
return '请至少填写一个域名';
}
const seen = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase();
if (!domainPattern.test(normalized)) {
return `域名格式不合法:${domain}`;
}
if (seen.has(normalized)) {
return `域名重复:${domain}`;
}
seen.add(normalized);
}
return null;
}
export function parseOriginUrls(value: string) {
const urls = linesFromTextarea(value);
if (urls.length === 0) {
return { urls: [], error: '请至少填写一个上游地址' };
}
let sharedScheme = '';
for (const originUrl of urls) {
let parsed: URL;
try {
parsed = new URL(originUrl);
} catch {
return { urls: [], error: `上游地址格式不合法:${originUrl}` };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return {
urls: [],
error: `上游地址必须以 http:// 或 https:// 开头:${originUrl}`,
};
}
if (!parsed.hostname) {
return { urls: [], error: `上游地址缺少主机名:${originUrl}` };
}
if (urls.length > 1) {
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search) {
return {
urls: [],
error: '多上游模式暂不支持带路径或查询参数的地址',
};
}
if (!sharedScheme) {
sharedScheme = parsed.protocol;
} else if (sharedScheme !== parsed.protocol) {
return {
urls: [],
error: '同一站点的多个上游必须使用相同协议',
};
}
}
}
return { urls, error: null };
}
export function parseOriginUrl(originUrl: string) {
const parsed = new URL(originUrl);
const port = parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
const path = parsed.pathname === '/' ? '' : parsed.pathname;
return {
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
address: parsed.hostname,
port,
uri: parsed.search ? `${path}${parsed.search}` || parsed.search : path,
};
}
export function buildOriginUrl(
scheme: 'http' | 'https',
address: string,
port: string,
uri: string,
) {
const normalizedAddress = address.trim();
const normalizedPort = port.trim();
const normalizedURI = uri.trim();
if (!normalizedAddress || !normalizedPort) {
return '';
}
const host =
normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')
? `[${normalizedAddress}]`
: normalizedAddress;
return `${scheme}://${host}:${normalizedPort}${normalizedURI}`;
}
export function validateOriginHost(value: string) {
const normalized = value.trim();
if (!normalized) {
return null;
}
if (
normalized.includes('://') ||
/[\/\\\s]/.test(normalized) ||
!originHostPattern.test(normalized)
) {
return '回源 Host 格式不合法';
}
return null;
}
export function parseCustomHeadersText(value: string) {
const lines = linesFromTextarea(value);
const headers: ProxyRouteCustomHeader[] = [];
for (const line of lines) {
const separatorIndex = line.indexOf(':');
if (separatorIndex <= 0) {
return {
headers: [],
error: `自定义请求头格式不合法:${line}`,
};
}
const key = line.slice(0, separatorIndex).trim();
const headerValue = line.slice(separatorIndex + 1).trim();
if (!headerKeyPattern.test(key)) {
return {
headers: [],
error: `自定义请求头名称不合法:${key}`,
};
}
headers.push({ key, value: headerValue });
}
return { headers, error: null };
}
export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
return headers.map((header) => `${header.key}: ${header.value}`).join('\n');
}
export function validateLimitRate(value: string) {
const normalized = value.trim();
if (!normalized || normalized === '0') {
return null;
}
if (!limitRatePattern.test(normalized)) {
return '限速格式不合法,请使用 512k、1m 或纯数字';
}
return null;
}
export function normalizeLimitRate(value: string) {
const normalized = value.trim().toLowerCase();
return normalized === '0' ? '' : normalized;
}
export function validateCacheRules(
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
) {
if (policy === 'url') {
return null;
}
if (rules.length === 0) {
return '当前缓存策略至少需要一条规则';
}
if (policy === 'suffix') {
for (const rule of rules) {
const normalized = rule.replace(/^\./, '');
if (!normalized || /[\/\\\s]/.test(normalized)) {
return `缓存后缀格式不合法:${rule}`;
}
}
return null;
}
for (const rule of rules) {
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
return `缓存路径规则格式不合法:${rule}`;
}
}
return null;
}
export function buildPayloadFromRoute(
route: ProxyRouteItem,
overrides: Partial<ProxyRouteMutationPayload>,
): ProxyRouteMutationPayload {
const primaryOrigin =
route.upstream_type === 'pages'
? parseOriginUrl('http://127.0.0.1')
: parseOriginUrl(route.origin_url);
return {
site_name: route.site_name,
domain: route.primary_domain,
domains: route.domains,
origin_id: null,
origin_url: route.origin_url,
origin_scheme: primaryOrigin.scheme,
origin_address: primaryOrigin.address,
origin_port: primaryOrigin.port,
origin_uri: primaryOrigin.uri,
origin_host: route.origin_host || '',
upstreams: route.upstream_list.slice(1),
enabled: route.enabled,
enable_https: route.enable_https,
cert_id: route.cert_id,
cert_ids: route.cert_ids,
domain_cert_ids: route.domain_cert_ids,
redirect_http: route.redirect_http,
limit_conn_per_server: route.limit_conn_per_server,
limit_conn_per_ip: route.limit_conn_per_ip,
limit_rate: route.limit_rate,
cache_enabled: route.cache_enabled,
cache_policy: route.cache_policy || 'url',
cache_rules: route.cache_rule_list,
custom_headers: route.custom_header_list,
remark: route.remark || '',
pow_enabled: route.pow_enabled,
pow_config: JSON.stringify(route.pow_config),
basic_auth_enabled: route.basic_auth_enabled,
basic_auth_username: route.basic_auth_username,
basic_auth_password: route.basic_auth_password,
upstream_type: route.upstream_type,
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: route.tunnel_target_protocol || '',
pages_project_id: route.pages_project_id ?? null,
...overrides,
};
}
export function getUpstreamSummary(route: ProxyRouteItem) {
if (route.upstream_type === 'pages') {
return route.pages_project_id
? `Pages 项目 #${route.pages_project_id}`
: 'Pages 项目未绑定';
}
if (route.upstream_type === 'tunnel') {
const protocol = route.tunnel_target_protocol || 'http';
const target = route.tunnel_target_addr || '未配置目标';
return `Tunnel → ${protocol}://${target}`;
}
if (route.upstream_list.length <= 1) {
return route.origin_url;
}
return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`;
}
export function getWebsiteStatusBadges(route: ProxyRouteItem) {
return [
route.enabled
? { label: '已启用', variant: 'success' as const }
: { label: '已停用', variant: 'warning' as const },
route.enable_https
? { label: 'HTTPS', variant: 'info' as const }
: { label: 'HTTP', variant: 'warning' as const },
];
}
@@ -0,0 +1,124 @@
export interface ProxyRouteCustomHeader {
key: string;
value: string;
}
export interface ProxyRoutePoWListConfig {
ips: string[];
ip_cidrs: string[];
paths: string[];
path_regexes: string[];
user_agents: string[];
}
export interface ProxyRoutePoWConfig {
difficulty: number;
algorithm: 'fast' | 'slow';
session_ttl: number;
challenge_ttl: number;
whitelist: ProxyRoutePoWListConfig;
blacklist: ProxyRoutePoWListConfig;
}
export interface ProxyRouteItem {
id: number;
site_name: string;
domain: string;
domains: string[];
primary_domain: string;
domain_count: number;
origin_id: number | null;
origin_url: string;
origin_host: string;
upstreams: string;
upstream_list: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids: number[];
domain_cert_ids: number[];
redirect_http: boolean;
limit_conn_per_server: number;
limit_conn_per_ip: number;
limit_rate: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string;
cache_rule_list: string[];
custom_headers: string;
custom_header_list: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
basic_auth_enabled: boolean;
basic_auth_username: string;
basic_auth_password: string;
remark: string;
upstream_type: 'direct' | 'tunnel' | 'pages';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
pages_project_id?: number | null;
created_at: string;
updated_at: string;
}
export interface ProxyRouteMutationPayload {
site_name?: string;
domain: string;
domains?: string[];
origin_id: number | null;
origin_url: string;
origin_scheme: 'http' | 'https';
origin_address: string;
origin_port: string;
origin_uri: string;
origin_host: string;
upstreams: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids?: number[];
domain_cert_ids?: number[];
redirect_http: boolean;
limit_conn_per_server?: number;
limit_conn_per_ip?: number;
limit_rate?: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string[];
custom_headers: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: string;
basic_auth_enabled: boolean;
basic_auth_username?: string;
basic_auth_password?: string;
remark: string;
upstream_type?: 'direct' | 'tunnel' | 'pages';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
pages_project_id?: number | null;
}
export interface TlsCertificateItem {
id: number;
name: string;
not_after?: string | null;
}
export interface ManagedDomainMatchCandidate {
managed_domain_id: number;
domain: string;
match_type: 'exact' | 'wildcard';
certificate_id: number;
certificate_name: string;
}
export interface ManagedDomainMatchResult {
domain: string;
matched: boolean;
candidate?: ManagedDomainMatchCandidate;
candidates: ManagedDomainMatchCandidate[];
}
@@ -0,0 +1,73 @@
import type { ManagedDomainItem } from '@/features/managed-domains/types';
export function isWildcardManagedDomain(domain: string) {
return domain.startsWith('*.');
}
export function buildRouteDomain(
managedDomain: string | undefined,
subdomainLabel: string,
) {
if (!managedDomain) {
return '';
}
if (!isWildcardManagedDomain(managedDomain)) {
return managedDomain.toLowerCase();
}
const normalizedLabel = subdomainLabel.trim().toLowerCase();
if (!normalizedLabel) {
return '';
}
return `${normalizedLabel}.${managedDomain.slice(2).toLowerCase()}`;
}
export function findManagedDomainForRoute(
routeDomain: string,
managedDomains: ManagedDomainItem[],
) {
const normalizedRouteDomain = routeDomain.trim().toLowerCase();
const exactMatch = managedDomains.find(
(item) => item.domain.toLowerCase() === normalizedRouteDomain,
);
if (exactMatch) {
return {
managedDomainId: exactMatch.domain,
subdomainLabel: '',
};
}
const wildcardMatch = managedDomains.find((item) => {
if (!isWildcardManagedDomain(item.domain)) {
return false;
}
const suffix = item.domain.slice(2).toLowerCase();
const suffixWithDot = `.${suffix}`;
if (!normalizedRouteDomain.endsWith(suffixWithDot)) {
return false;
}
const label = normalizedRouteDomain.slice(
0,
normalizedRouteDomain.length - suffixWithDot.length,
);
return Boolean(label) && !label.includes('.');
});
if (!wildcardMatch) {
return null;
}
return {
managedDomainId: wildcardMatch.domain,
subdomainLabel: normalizedRouteDomain.slice(
0,
normalizedRouteDomain.length - wildcardMatch.domain.slice(1).length,
),
};
}