1import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";
2import * as Schema from "effect/Schema";
3
4const DNS_LABEL_MAX_LENGTH = 63;
5const MANAGED_ENDPOINT_HASH_LENGTH = 16;
6const MANAGED_ENDPOINT_TUNNEL_PREFIX = "t3coderelay-managedendpoint";
7export const MANAGED_ENDPOINT_ZONE_OWNER_STAGE = "prod";
8
9export class RelayPublicDomainLabelTooLongError extends Schema.TaggedError<RelayPublicDomainLabelTooLongError>()(
10 "RelayPublicDomainLabelTooLongError",
11 {
12 stage: Schema.String,
13 label: Schema.String,
14 maxLength: Schema.Number,
15 },
16) {
17 override get message(): string {
18 return `Relay stage '${this.stage}' produces custom domain label '${this.label}' (${this.label.length} characters), exceeding the DNS label limit of ${this.maxLength}.`;
19 }
20}
21
22function normalizeZoneName(zoneName: string): string {
23 return zoneName
24 .trim()
25 .toLowerCase()
26 .replace(/^\.+|\.+$/g, "");
27}
28
29function isDnsName(name: string): boolean {
30 return (
31 name.length > 0 &&
32 name.length <= 253 &&
33 name
34 .split(".")
35 .every(
36 (label) =>
37 label.length > 0 &&
38 label.length <= DNS_LABEL_MAX_LENGTH &&
39 /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label),
40 )
41 );
42}
43
44function stableSuffix(hash: string): string {
45 return hash.toLowerCase().slice(0, MANAGED_ENDPOINT_HASH_LENGTH);
46}
47
48function appendDnsSafeSuffix(prefix: string, suffix: string): string {
49 const truncatedPrefix = prefix
50 .slice(0, DNS_LABEL_MAX_LENGTH - suffix.length - 1)
51 .replace(/-+$/g, "");
52 return `${truncatedPrefix}-${suffix}`;
53}
54
55/**
56 * Alchemy's physical-name helper sanitizes resource names after adding the
57 * stage. Keep custom domains and runtime-created resources aligned with it.
58 */
59function relayStageSlug(stage: string): string {
60 return stage
61 .toLowerCase()
62 .replaceAll(/[^a-z0-9-]/g, "-")
63 .replaceAll(/-+/g, "-")
64 .replace(/^-+|-+$/g, "");
65}
66
67export function relayResourceNameForStage(name: string, stage: string): string {
68 return `${name}-${relayStageSlug(stage)}`;
69}
70
71export function relayOwnsManagedEndpointZone(stage: string): boolean {
72 return stage === MANAGED_ENDPOINT_ZONE_OWNER_STAGE;
73}
74
75export function relayPublicDomainForStage(stage: string, zoneName: string): string {
76 const stageSlug = relayStageSlug(stage);
77 const relayLabel = stage === "prod" ? "relay" : `relay-${stageSlug}`;
78 if (relayLabel.length > DNS_LABEL_MAX_LENGTH) {
79 throw new RelayPublicDomainLabelTooLongError({
80 stage,
81 label: relayLabel,
82 maxLength: DNS_LABEL_MAX_LENGTH,
83 });
84 }
85 return `${relayLabel}.${normalizeZoneName(zoneName)}`;
86}
87
88export function managedEndpointDigestInput(
89 stage: string,
90 userId: string,
91 environmentId: string,
92): string {
93 return `${stage}:${userId}:${environmentId}`;
94}
95
96export function managedEndpointHostname(stage: string, baseDomain: string, hash: string): string {
97 const label = appendDnsSafeSuffix(relayStageSlug(stage), stableSuffix(hash));
98 return `${label}.${normalizeZoneName(baseDomain)}`;
99}
100
101export function isManagedEndpointHostname(hostname: string, baseDomain: string): boolean {
102 const normalizedHostname = normalizeZoneName(hostname);
103 const normalizedBaseDomain = normalizeZoneName(baseDomain);
104 return (
105 hostname === normalizedHostname &&
106 isDnsName(normalizedHostname) &&
107 isDnsName(normalizedBaseDomain) &&
108 normalizedHostname.endsWith(`.${normalizedBaseDomain}`)
109 );
110}
111
112export function managedEndpointForHostname(hostname: string): RelayManagedEndpoint {
113 return {
114 httpBaseUrl: `https://${hostname}/`,
115 wsBaseUrl: `wss://${hostname}/ws`,
116 providerKind: "cloudflare_tunnel",
117 };
118}
119
120export function managedEndpointTunnelNamePrefix(stage: string): string {
121 return `${MANAGED_ENDPOINT_TUNNEL_PREFIX}-${relayStageSlug(stage)}-`;
122}
123
124export function managedEndpointTunnelName(stage: string, hash: string): string {
125 return `${managedEndpointTunnelNamePrefix(stage)}${stableSuffix(hash)}`;
126}
127
128/**
129 * A managed endpoint's public key in webhook URLs: the hash suffix its tunnel
130 * name ends with. The hash covers user and environment, so one key names
131 * exactly one link, unlike the environment id, which any account can claim.
132 */
133export const MANAGED_ENDPOINT_KEY_PATTERN = new RegExp(
134 `^[0-9a-f]{${MANAGED_ENDPOINT_HASH_LENGTH}}$`,
135);
136
137export function managedEndpointTunnelNameForKey(stage: string, endpointKey: string): string {
138 return `${managedEndpointTunnelNamePrefix(stage)}${endpointKey}`;
139}