Skip to content

Commit 3cc2f3b

Browse files
authored
feat: improve automatic domain configuration (#5289)
- added official entrijs with types and mimimal runtime (no dependencies) - automatically add `www.` prefix to non-cloudflare root domains - enabled automatic www redirect to setup with entri (though does not work seamlesly, namecheap requires ssl certificate, godaddy requires manual setup by user) - toast error when click on "Configure automatically" on free plan
1 parent 7c05003 commit 3cc2f3b

File tree

10 files changed

+257
-308
lines changed

10 files changed

+257
-308
lines changed

apps/builder/app/builder/features/topbar/add-domain.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { Project } from "@webstudio-is/project";
1414
import { useId, useOptimistic, useRef, useState } from "react";
1515
import { TerminalIcon } from "@webstudio-is/icons";
1616
import { nativeClient } from "~/shared/trpc/trpc-client";
17+
import { extractCname } from "./cname";
1718

1819
type DomainsAddProps = {
1920
projectId: Project["id"];
@@ -38,14 +39,26 @@ export const AddDomain = ({
3839
// Will be automatically reset on action end
3940
setIsPendingOptimistic(true);
4041

41-
const domain = formData.get("domain")?.toString() ?? "";
42+
let domain = formData.get("domain")?.toString() ?? "";
4243
const validationResult = validateDomain(domain);
4344

4445
if (validationResult.success === false) {
4546
setError(validationResult.error);
4647
return;
4748
}
4849

50+
// detect provider only when root domain is specified
51+
if (extractCname(domain) === "@") {
52+
const registrar = await nativeClient.domain.findDomainRegistrar.query({
53+
domain,
54+
});
55+
// enforce www subdomain when no support for cname flattening
56+
// and root cname can conflict with MX or NS
57+
if (!registrar.cnameFlattening) {
58+
domain = `www.${domain}`;
59+
}
60+
}
61+
4962
const result = await nativeClient.domain.create.mutate({
5063
domain,
5164
projectId,

apps/builder/app/builder/features/topbar/domain-checkbox.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ interface DomainCheckboxProps {
2020
disabled?: boolean;
2121
}
2222

23-
const DomainCheckbox = (props: DomainCheckboxProps) => {
23+
export const DomainCheckbox = (props: DomainCheckboxProps) => {
2424
const hasProPlan = useStore($userPlanFeatures).hasProPlan;
2525
const project = useStore($project);
2626

@@ -78,5 +78,3 @@ const DomainCheckbox = (props: DomainCheckboxProps) => {
7878
</div>
7979
);
8080
};
81-
82-
export default DomainCheckbox;

apps/builder/app/builder/features/topbar/domains.tsx

Lines changed: 86 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "@webstudio-is/icons";
2222
import { CollapsibleDomainSection } from "./collapsible-domain-section";
2323
import {
24+
Fragment,
2425
startTransition,
2526
useEffect,
2627
useOptimistic,
@@ -34,12 +35,13 @@ import { useStore } from "@nanostores/react";
3435
import { $publisherHost } from "~/shared/nano-states";
3536
import { extractCname } from "./cname";
3637
import { useEffectEvent } from "~/shared/hook-utils/effect-event";
37-
import DomainCheckbox from "./domain-checkbox";
38+
import { DomainCheckbox } from "./domain-checkbox";
3839
import { CopyToClipboard } from "~/builder/shared/copy-to-clipboard";
3940
import { RelativeTime } from "~/builder/shared/relative-time";
4041

4142
export type Domain = Project["domainsVirtual"][number];
42-
type DomainStatus = Project["domainsVirtual"][number]["status"];
43+
44+
type DomainStatus = Domain["status"];
4345

4446
const InputEllipsis = styled(InputField, {
4547
"&>input": {
@@ -164,23 +166,28 @@ const StatusIcon = (props: { projectDomain: Domain; isLoading: boolean }) => {
164166
);
165167
};
166168

167-
const DomainItem = (props: {
169+
const DomainItem = ({
170+
initiallyOpen,
171+
projectDomain,
172+
project,
173+
refresh,
174+
}: {
168175
initiallyOpen: boolean;
169176
projectDomain: Domain;
170-
refresh: () => Promise<void>;
171177
project: Project;
178+
refresh: () => Promise<void>;
172179
}) => {
173180
const timeSinceLastUpdateMs =
174-
Date.now() - new Date(props.projectDomain.updatedAt).getTime();
181+
Date.now() - new Date(projectDomain.updatedAt).getTime();
175182

176183
const DAY_IN_MS = 24 * 60 * 60 * 1000;
177184

178-
const status = props.projectDomain.verified
179-
? (`VERIFIED_${props.projectDomain.status}` as `VERIFIED_${DomainStatus}`)
185+
const status = projectDomain.verified
186+
? (`VERIFIED_${projectDomain.status}` as `VERIFIED_${DomainStatus}`)
180187
: `UNVERIFIED`;
181188

182189
const [isStatusLoading, setIsStatusLoading] = useState(
183-
props.initiallyOpen ||
190+
initiallyOpen ||
184191
status === "VERIFIED_ACTIVE" ||
185192
timeSinceLastUpdateMs > DAY_IN_MS
186193
? false
@@ -195,16 +202,16 @@ const DomainItem = (props: {
195202
const handleRemoveDomain = async () => {
196203
setIsRemoveInProgress(true);
197204
const result = await nativeClient.domain.remove.mutate({
198-
projectId: props.projectDomain.projectId,
199-
domainId: props.projectDomain.domainId,
205+
projectId: projectDomain.projectId,
206+
domainId: projectDomain.domainId,
200207
});
201208

202209
if (result.success === false) {
203210
toast.error(result.error);
204211
return;
205212
}
206213

207-
await props.refresh();
214+
await refresh();
208215
};
209216

210217
const [verifyError, setVerifyError] = useState<string | undefined>(undefined);
@@ -214,16 +221,16 @@ const DomainItem = (props: {
214221
setIsCheckStateInProgress(true);
215222

216223
const verifyResult = await nativeClient.domain.verify.mutate({
217-
projectId: props.projectDomain.projectId,
218-
domainId: props.projectDomain.domainId,
224+
projectId: projectDomain.projectId,
225+
domainId: projectDomain.domainId,
219226
});
220227

221228
if (verifyResult.success === false) {
222229
setVerifyError(verifyResult.error);
223230
return;
224231
}
225232

226-
await props.refresh();
233+
await refresh();
227234
});
228235

229236
const [updateStatusError, setUpdateStatusError] = useState<
@@ -235,8 +242,8 @@ const DomainItem = (props: {
235242
setIsCheckStateInProgress(true);
236243

237244
const updateStatusResult = await nativeClient.domain.updateStatus.mutate({
238-
projectId: props.projectDomain.projectId,
239-
domain: props.projectDomain.domain,
245+
projectId: projectDomain.projectId,
246+
domain: projectDomain.domain,
240247
});
241248

242249
setIsStatusLoading(false);
@@ -246,7 +253,7 @@ const DomainItem = (props: {
246253
return;
247254
}
248255

249-
await props.refresh();
256+
await refresh();
250257
});
251258

252259
const onceRef = useRef(false);
@@ -272,67 +279,60 @@ const DomainItem = (props: {
272279
});
273280
}, [status, handleVerify, handleUpdateStatus, isStatusLoading]);
274281

275-
const publisherHost = useStore($publisherHost);
276-
const cnameEntryName = extractCname(props.projectDomain.domain);
277-
const cnameEntryValue = `${props.projectDomain.cname}.customers.${publisherHost}`;
278-
279-
const txtEntryName =
280-
cnameEntryName === "@"
281-
? "_webstudio_is"
282-
: `_webstudio_is.${cnameEntryName}`;
283-
284-
const domainStatus = getStatus(props.projectDomain);
285-
286-
const cnameRecord = {
287-
type: "CNAME",
288-
host: cnameEntryName,
289-
value: cnameEntryValue,
290-
ttl: 300,
291-
} as const;
292-
293-
const txtRecord = {
294-
type: "TXT",
295-
host: txtEntryName,
296-
value: props.projectDomain.expectedTxtRecord,
297-
ttl: 300,
298-
} as const;
299-
300-
const dnsRecords = [cnameRecord, txtRecord];
282+
const domainStatus = getStatus(projectDomain);
301283

302284
const { isVerifiedActive, text } = getStatusText({
303-
projectDomain: props.projectDomain,
285+
projectDomain,
304286
isLoading: false,
305287
});
306288

289+
const publisherHost = useStore($publisherHost);
290+
const cname = extractCname(projectDomain.domain);
291+
const dnsRecords = [
292+
{
293+
type: "CNAME",
294+
host: cname,
295+
value: `${projectDomain.cname}.customers.${publisherHost}`,
296+
ttl: 300,
297+
} as const,
298+
{
299+
type: "TXT",
300+
host: cname === "@" ? "_webstudio_is" : `_webstudio_is.${cname}`,
301+
value: projectDomain.expectedTxtRecord,
302+
ttl: 300,
303+
} as const,
304+
];
305+
307306
return (
308307
<CollapsibleDomainSection
309308
prefix={
310309
<DomainCheckbox
311-
buildId={props.projectDomain.latestBuildVirtual?.buildId}
310+
buildId={projectDomain.latestBuildVirtual?.buildId}
312311
defaultChecked={
313-
props.projectDomain.latestBuildVirtual?.buildId != null &&
314-
props.projectDomain.latestBuildVirtual?.buildId ===
315-
props.project.latestBuildVirtual?.buildId
312+
projectDomain.latestBuildVirtual?.buildId != null &&
313+
projectDomain.latestBuildVirtual?.buildId ===
314+
project.latestBuildVirtual?.buildId
316315
}
317-
domain={props.projectDomain.domain}
316+
domain={projectDomain.domain}
318317
disabled={domainStatus !== "VERIFIED_ACTIVE"}
319318
/>
320319
}
321-
initiallyOpen={props.initiallyOpen}
322-
title={props.projectDomain.domain}
320+
initiallyOpen={initiallyOpen}
321+
title={projectDomain.domain}
323322
suffix={
324323
<Grid flow="column">
325324
<StatusIcon
326325
isLoading={isStatusLoading}
327-
projectDomain={props.projectDomain}
326+
projectDomain={projectDomain}
328327
/>
329328

330-
<Tooltip content={`Proceed to ${props.projectDomain.domain}`}>
329+
<Tooltip content={`Proceed to ${projectDomain.domain}`}>
331330
<IconButton
331+
type="button"
332332
tabIndex={-1}
333333
disabled={status !== "VERIFIED_ACTIVE"}
334334
onClick={(event) => {
335-
const url = new URL(`https://${props.projectDomain.domain}`);
335+
const url = new URL(`https://${projectDomain.domain}`);
336336
window.open(url.href, "_blank");
337337
event.preventDefault();
338338
}}
@@ -423,67 +423,45 @@ const DomainItem = (props: {
423423

424424
<Grid
425425
gap={2}
426-
css={{
427-
gridTemplateColumns: `${theme.spacing[18]} 1fr 1fr`,
428-
}}
426+
css={{ gridTemplateColumns: `${theme.spacing[18]} 1fr 1fr` }}
429427
>
430-
<Text color="subtle" variant={"titles"}>
428+
<Text color="subtle" variant="titles">
431429
TYPE
432430
</Text>
433-
<Text color="subtle" variant={"titles"}>
431+
<Text color="subtle" variant="titles">
434432
NAME
435433
</Text>
436-
<Text color="subtle" variant={"titles"}>
434+
<Text color="subtle" variant="titles">
437435
VALUE
438436
</Text>
439437

440-
<InputEllipsis readOnly value="CNAME" />
441-
<InputEllipsis
442-
readOnly
443-
value={cnameRecord.host}
444-
suffix={
445-
<CopyToClipboard text={cnameRecord.host}>
446-
<NestedInputButton type="button">
447-
<CopyIcon />
448-
</NestedInputButton>
449-
</CopyToClipboard>
450-
}
451-
/>
452-
<InputEllipsis
453-
readOnly
454-
value={cnameRecord.value}
455-
suffix={
456-
<CopyToClipboard text={cnameRecord.value}>
457-
<NestedInputButton type="button">
458-
<CopyIcon />
459-
</NestedInputButton>
460-
</CopyToClipboard>
461-
}
462-
/>
463-
464-
<InputEllipsis readOnly value="TXT" />
465-
<InputEllipsis
466-
readOnly
467-
value={txtRecord.host}
468-
suffix={
469-
<CopyToClipboard text={txtRecord.host}>
470-
<NestedInputButton type="button">
471-
<CopyIcon />
472-
</NestedInputButton>
473-
</CopyToClipboard>
474-
}
475-
/>
476-
<InputEllipsis
477-
readOnly
478-
value={txtRecord.value}
479-
suffix={
480-
<CopyToClipboard text={txtRecord.value}>
481-
<NestedInputButton type="button">
482-
<CopyIcon />
483-
</NestedInputButton>
484-
</CopyToClipboard>
485-
}
486-
/>
438+
{dnsRecords.map((record, index) => (
439+
<Fragment key={index}>
440+
<InputEllipsis readOnly value={record.type} />
441+
<InputEllipsis
442+
readOnly
443+
value={record.host}
444+
suffix={
445+
<CopyToClipboard text={record.host}>
446+
<NestedInputButton type="button">
447+
<CopyIcon />
448+
</NestedInputButton>
449+
</CopyToClipboard>
450+
}
451+
/>
452+
<InputEllipsis
453+
readOnly
454+
value={record.value}
455+
suffix={
456+
<CopyToClipboard text={record.value}>
457+
<NestedInputButton type="button">
458+
<CopyIcon />
459+
</NestedInputButton>
460+
</CopyToClipboard>
461+
}
462+
/>
463+
</Fragment>
464+
))}
487465
</Grid>
488466

489467
<Grid
@@ -500,7 +478,7 @@ const DomainItem = (props: {
500478

501479
<Entri
502480
dnsRecords={dnsRecords}
503-
domain={props.projectDomain.domain}
481+
domain={projectDomain.domain}
504482
onClose={() => {
505483
// Sometimes Entri modal dialog hangs even if it's successful,
506484
// until they fix that, we'll just refresh the status here on every onClose event

0 commit comments

Comments
 (0)