1import { withEnvironmentConfigLock } from "./transient.ts";
2import {
3 waitUntilDeleted,
4 projectServices as fetchProjectServices,
5} from "./GraphQL.ts";
6import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
7import {
8 Railway,
9 type CustomDomain as RailwayCustomDomain,
10} from "@distilled.cloud/railway";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14import { isResolved } from "../Diff.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import { matchesAlchemyPhysicalName } from "./Metadata.ts";
18import { ownedProjects, projectEnvironmentIds } from "./Project.ts";
19import type { Providers } from "./Providers.ts";
20
21const domainFields = <E>(domain: Query<RailwayCustomDomain, E>) => ({
22 id: domain.id,
23 domain: domain.domain,
24 serviceId: domain.serviceId,
25 environmentId: domain.environmentId,
26 projectId: domain.projectId,
27 targetPort: domain.targetPort,
28 deletedAt: domain.deletedAt,
29 syncStatus: domain.syncStatus,
30 status: {
31 verified: domain.status.verified,
32 certificateStatus: domain.status.certificateStatus,
33 certificateErrorMessage: domain.status.certificateErrorMessage,
34 verificationDnsHost: domain.status.verificationDnsHost,
35 verificationToken: domain.status.verificationToken,
36 },
37});
38type CloudDomain = UnwrapPlan<ReturnType<typeof domainFields>>;
39
40const readCustomDomain = Query.fn((id: string, projectId: string) =>
41 domainFields(Railway.customDomain({ id, projectId })),
42);
43
44const readCustomDomains = Query.fn(
45 (projectId: string, environmentId: string, serviceId: string) =>
46 Railway.domains({ environmentId, projectId, serviceId }).customDomains.pipe(
47 Query.map(domainFields),
48 ),
49);
50
51const customDomainCreate = Query.fn(
52 (input: {
53 domain: string;
54 environmentId: string;
55 projectId: string;
56 serviceId: string;
57 targetPort?: number;
58 }) => domainFields(Railway.customDomainCreate({ input })),
59);
60
61const customDomainUpdate = Query.fn(
62 (input: { environmentId: string; id: string; targetPort: number }) =>
63 Railway.customDomainUpdate(input),
64);
65
66const customDomainDelete = Query.fn((id: string) =>
67 Railway.customDomainDelete({ id }),
68);
69
70/**
71 * A resource-valued prop: the resource itself, or an Effect that produces
72 * it (so `yield* Service(...)` and `Service(...)` both type-check).
73 */
74type Ref<T> = T | Effect.Effect<T, never, Providers>;
75
76/**
77 * Parent Service identity. A `Railway.Service` resource satisfies this
78 * via `serviceId`. Tests may pass a `{ serviceId }` stub created out of
79 * band. `projectId` is optional when the environment carries it (a
80 * `Railway.Project`).
81 */
82export type CustomDomainService = {
83 readonly serviceId: string;
84 readonly projectId?: string;
85};
86
87/**
88 * Environment identity a custom domain is created in. Accepts a
89 * `Railway.Project` (its primary environment), a `Railway.Environment`,
90 * or an `{ environmentId }` stub.
91 */
92export type CustomDomainEnvironment = {
93 readonly environmentId: string;
94 readonly projectId?: string;
95};
96
97export interface CustomDomainProps {
98 /**
99 * Parent Railway Service. Accepts a `Railway.Service` resource or a
100 * `{ serviceId }` stub. Changing the Service replaces the CustomDomain.
101 */
102 service: Ref<CustomDomainService>;
103 /**
104 * Environment to attach the hostname in. Accepts a `Railway.Project`
105 * (primary environment), a `Railway.Environment`, or `{ environmentId }`.
106 * Changing it replaces the CustomDomain.
107 */
108 environment: Ref<CustomDomainEnvironment>;
109 /**
110 * User hostname (`www.example.com`). Identity of the resource.
111 * Changing it replaces the CustomDomain.
112 */
113 domain: string;
114 /**
115 * Service port Railway's edge should target. Updates in place via
116 * `customDomainUpdate`. Omit to let Railway pick the service's public
117 * port.
118 */
119 targetPort?: number;
120}
121
122export type CustomDomain = Resource<
123 "Railway.CustomDomain",
124 CustomDomainProps,
125 {
126 /** Railway custom domain id. */
127 customDomainId: string;
128 /** User hostname. */
129 domain: string;
130 /** Parent Railway service id. */
131 serviceId: string;
132 /** Parent Railway project id. */
133 projectId: string;
134 /** Environment the hostname is attached in. */
135 environmentId: string;
136 /** Observed target port, if set. */
137 targetPort: number | undefined;
138 /** Whether DNS ownership has been verified. */
139 verified: boolean | undefined;
140 /** ACME certificate issuance status, if the API returned one. */
141 certificateStatus: string | undefined;
142 /** Human-readable ACME error, if issuance failed. */
143 certificateErrorMessage: string | undefined;
144 /** DNS host Railway expects for the verification TXT record. */
145 verificationDnsHost: string | undefined;
146 /** DNS token Railway expects for the verification TXT record. */
147 verificationToken: string | undefined;
148 /** Observed sync status (`ACTIVE`, `CREATING`, …). */
149 syncStatus: string | undefined;
150 /** `https://{domain}`. */
151 url: string;
152 },
153 never,
154 Providers
155>;
156
157/**
158 * A Railway.CustomDomain is a user hostname on a {@link CustomDomainService}.
159 * Railway issues a Let's Encrypt certificate once DNS is verified.
160 *
161 * @see https://docs.railway.com/guides/public-networking
162 *
163 * ### Attach a hostname
164 * Pass the parent Service, the environment id, and the hostname. Yield the
165 * CustomDomain next to the Service. Point DNS at the values in
166 * `verificationDnsHost` / `verificationToken`.
167 *
168 * **Example:** Basic CustomDomain
169 * ```typescript
170 * const site = yield* Railway.Project("Site");
171 * const api = yield* Railway.Service("Api", {
172 * project: site,
173 * image: "hashicorp/http-echo",
174 * });
175 * const www = yield* Railway.CustomDomain("Www", {
176 * service: api,
177 * environment: site,
178 * domain: "www.example.com",
179 * });
180 * ```
181 *
182 * :::caution[Changing `domain`, `service`, or `environment` replaces the CustomDomain]
183 * The old hostname is deleted. The new hostname is created.
184 * :::
185 *
186 * ### Target port
187 * `targetPort` is optional and updates in place. Railway's edge forwards
188 * HTTPS on 443 to this port on the Service.
189 *
190 * **Example:** Explicit target port
191 * ```typescript
192 * const www = yield* Railway.CustomDomain("Www", {
193 * service: api,
194 * environment: site,
195 * domain: "www.example.com",
196 * targetPort: 8080,
197 * });
198 * ```
199 *
200 * ### Module-scope declarations
201 * Declare the CustomDomain once. Resource-valued props accept the resource
202 * or an Effect producing it.
203 *
204 * **Example:** Module-scope CustomDomain
205 * ```typescript
206 * // src/domain.ts
207 * import * as Railway from "alchemy/Railway";
208 * import { Api } from "./api.ts";
209 * import { Site } from "./project.ts";
210 *
211 * export const Www = Railway.CustomDomain("Www", {
212 * service: Api,
213 * environment: Site,
214 * domain: "www.example.com",
215 * });
216 * ```
217 *
218 * @resource
219 * @product Networking
220 */
221export const CustomDomain = Resource<CustomDomain>("Railway.CustomDomain");
222
223export class CustomDomainNotCreated extends Data.TaggedError(
224 "Railway.CustomDomainNotCreated",
225)<{
226 domain: string;
227 serviceId: string;
228}> {}
229
230export class CustomDomainServiceMissing extends Data.TaggedError(
231 "Railway.CustomDomainServiceMissing",
232)<{
233 domain: string;
234}> {}
235
236const serviceIdOf = (value: unknown): string | undefined => {
237 if (value === null || typeof value !== "object") return undefined;
238 const rec = value as { serviceId?: unknown };
239 if (typeof rec.serviceId === "string" && rec.serviceId.length > 0) {
240 return rec.serviceId;
241 }
242 return undefined;
243};
244
245const projectIdOf = (value: unknown): string | undefined => {
246 if (value === null || typeof value !== "object") return undefined;
247 const rec = value as { projectId?: unknown };
248 if (typeof rec.projectId === "string" && rec.projectId.length > 0) {
249 return rec.projectId;
250 }
251 return undefined;
252};
253
254const environmentIdOf = (value: unknown): string | undefined => {
255 if (value === null || typeof value !== "object") return undefined;
256 const rec = value as { environmentId?: unknown };
257 if (typeof rec.environmentId === "string" && rec.environmentId.length > 0) {
258 return rec.environmentId;
259 }
260 return undefined;
261};
262
263const isGone = (domain: CloudDomain | undefined) =>
264 domain === undefined ||
265 domain.deletedAt != null ||
266 domain.syncStatus === "DELETED";
267
268const toAttrs = (
269 domain: CloudDomain,
270 fallback?: { projectId?: string; environmentId?: string },
271): CustomDomain["Attributes"] => {
272 const status = domain.status;
273 const name = domain.domain;
274 return {
275 customDomainId: domain.id,
276 domain: name,
277 serviceId: domain.serviceId,
278 projectId: domain.projectId ?? fallback?.projectId ?? "",
279 environmentId: domain.environmentId || fallback?.environmentId || "",
280 targetPort: domain.targetPort ?? undefined,
281 verified: status.verified,
282 certificateStatus: status.certificateStatus,
283 certificateErrorMessage: status.certificateErrorMessage ?? undefined,
284 verificationDnsHost: status.verificationDnsHost ?? undefined,
285 verificationToken: status.verificationToken ?? undefined,
286 syncStatus: domain.syncStatus,
287 url: `https://${name}`,
288 };
289};
290
291const getById = (customDomainId: string, projectId: string) =>
292 readCustomDomain(customDomainId, projectId).pipe(
293 Effect.map((domain) => (isGone(domain) ? undefined : domain)),
294 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
295 );
296
297const listServiceDomains = (
298 projectId: string,
299 environmentId: string,
300 serviceId: string,
301) =>
302 readCustomDomains(projectId, environmentId, serviceId).pipe(
303 Effect.map((customDomains) =>
304 customDomains.filter((domain) => !isGone(domain)),
305 ),
306 Effect.catchTag("RailwayNotFound", () =>
307 Effect.succeed([] as ReadonlyArray<CloudDomain>),
308 ),
309 );
310
311const findByDomain = (
312 projectId: string,
313 environmentId: string,
314 serviceId: string,
315 domain: string,
316) =>
317 listServiceDomains(projectId, environmentId, serviceId).pipe(
318 Effect.map((domains) =>
319 domains.find(
320 (item) => item.domain.toLowerCase() === domain.toLowerCase(),
321 ),
322 ),
323 );
324
325const observe = Effect.fn(function* (input: {
326 customDomainId?: string;
327 projectId: string;
328 environmentId: string;
329 serviceId: string;
330 domain: string;
331}) {
332 if (
333 input.customDomainId !== undefined &&
334 input.customDomainId.length > 0 &&
335 input.projectId.length > 0
336 ) {
337 const byId = yield* getById(input.customDomainId, input.projectId);
338 if (byId !== undefined) return byId;
339 }
340 const listed = yield* findByDomain(
341 input.projectId,
342 input.environmentId,
343 input.serviceId,
344 input.domain,
345 );
346 if (listed === undefined) return undefined;
347 if (listed.projectId !== null && listed.projectId.length > 0) {
348 const full = yield* getById(listed.id, listed.projectId);
349 if (full !== undefined) return full;
350 }
351 return listed;
352});
353
354const waitUntilGone = (customDomainId: string, projectId: string) =>
355 waitUntilDeleted(
356 "CustomDomain",
357 customDomainId,
358 getById(customDomainId, projectId).pipe(
359 Effect.map((domain) => domain === undefined),
360 ),
361 );
362
363export const CustomDomainProvider = () =>
364 Provider.succeed(CustomDomain, {
365 stables: [
366 "customDomainId",
367 "serviceId",
368 "projectId",
369 "environmentId",
370 "domain",
371 ],
372 nuke: { dependsOn: ["Railway.Project"] },
373
374 list: Effect.fn(function* () {
375 const projects = yield* ownedProjects();
376 const rows = yield* Effect.forEach(projects, (project) =>
377 Effect.gen(function* () {
378 const live = yield* fetchProjectServices(
379 project.projectId,
380 (service) => ({
381 id: service.id,
382 name: service.name,
383 deletedAt: service.deletedAt,
384 }),
385 ).pipe(
386 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
387 );
388 const services = (live ?? []).filter(
389 (service) =>
390 service.deletedAt == null &&
391 matchesAlchemyPhysicalName(service.name),
392 );
393 const envIds = yield* projectEnvironmentIds(project);
394 const nested = yield* Effect.forEach(services, (service) =>
395 Effect.forEach(envIds, (environmentId) =>
396 listServiceDomains(
397 project.projectId,
398 environmentId,
399 service.id,
400 ).pipe(
401 Effect.map((domains) =>
402 domains.map((domain) =>
403 toAttrs(domain, {
404 projectId: project.projectId,
405 environmentId,
406 }),
407 ),
408 ),
409 ),
410 ).pipe(Effect.map((rows) => rows.flat())),
411 );
412 return nested.flat();
413 }),
414 );
415 return rows.flat();
416 }),
417
418 diff: Effect.fn(function* ({ news, output }) {
419 if (news === undefined || !isResolved(news)) return undefined;
420 if (output === undefined) return undefined;
421 const serviceId = serviceIdOf(news.service);
422 const serviceChanged =
423 serviceId !== undefined && serviceId !== output.serviceId;
424 const environmentId = environmentIdOf(news.environment);
425 const environmentChanged =
426 environmentId !== undefined && environmentId !== output.environmentId;
427 const domainChanged =
428 news.domain.toLowerCase() !== output.domain.toLowerCase();
429 if (serviceChanged || environmentChanged || domainChanged) {
430 return {
431 action: "replace" as const,
432 deleteFirst: !domainChanged,
433 };
434 }
435 return undefined;
436 }),
437
438 read: Effect.fn(function* ({ olds, output }) {
439 const serviceId = output?.serviceId ?? serviceIdOf(olds?.service) ?? "";
440 const projectId =
441 output?.projectId ??
442 projectIdOf(olds?.service) ??
443 projectIdOf(olds?.environment) ??
444 "";
445 const environmentId =
446 output?.environmentId ?? environmentIdOf(olds?.environment) ?? "";
447 const domain = output?.domain ?? olds?.domain;
448 if (
449 serviceId.length === 0 ||
450 projectId.length === 0 ||
451 environmentId.length === 0 ||
452 domain === undefined ||
453 domain.length === 0
454 ) {
455 return undefined;
456 }
457 const found = yield* observe({
458 customDomainId: output?.customDomainId,
459 projectId,
460 environmentId,
461 serviceId,
462 domain,
463 });
464 if (found === undefined) return undefined;
465 return toAttrs(found, { projectId, environmentId });
466 }),
467
468 reconcile: Effect.fn(function* ({ news, output }) {
469 const props = news ?? ({} as CustomDomainProps);
470 const serviceId = serviceIdOf(props.service) ?? output?.serviceId ?? "";
471 const projectId =
472 projectIdOf(props.service) ??
473 projectIdOf(props.environment) ??
474 output?.projectId ??
475 "";
476 const environmentId =
477 environmentIdOf(props.environment) ?? output?.environmentId ?? "";
478 const domain = props.domain ?? output?.domain ?? "";
479 if (serviceId.length === 0 || projectId.length === 0) {
480 return yield* new CustomDomainServiceMissing({ domain });
481 }
482 if (domain.length === 0 || environmentId.length === 0) {
483 return yield* new CustomDomainNotCreated({ domain, serviceId });
484 }
485
486 let current = yield* observe({
487 customDomainId: output?.customDomainId,
488 projectId,
489 environmentId,
490 serviceId,
491 domain: output?.domain ?? domain,
492 });
493 if (
494 current === undefined &&
495 (output?.domain !== domain ||
496 output?.serviceId !== serviceId ||
497 output?.environmentId !== environmentId)
498 ) {
499 current = yield* observe({
500 projectId,
501 environmentId,
502 serviceId,
503 domain,
504 });
505 }
506
507 if (current === undefined) {
508 const ensureDomain = Effect.gen(function* () {
509 // A previous attempt may have created the hostname before its response failed.
510 const observed = yield* observe({
511 projectId,
512 environmentId,
513 serviceId,
514 domain,
515 });
516 if (observed !== undefined) return observed;
517 return yield* customDomainCreate({
518 domain,
519 environmentId,
520 projectId,
521 serviceId,
522 ...(props.targetPort !== undefined
523 ? { targetPort: props.targetPort }
524 : {}),
525 }).pipe(
526 Effect.catchTag(
527 ["RailwayCustomDomainCreateFailed", "RailwayValidationError"],
528 (failure) =>
529 observe({ projectId, environmentId, serviceId, domain }).pipe(
530 Effect.flatMap((found) =>
531 found !== undefined
532 ? Effect.succeed(found)
533 : Effect.fail(failure),
534 ),
535 ),
536 ),
537 );
538 });
539 current = yield* withEnvironmentConfigLock(
540 environmentId,
541 ensureDomain,
542 ).pipe(
543 Effect.retry({
544 while: (error) => error._tag === "RailwayCustomDomainCreateFailed",
545 times: 8,
546 schedule: Schedule.spaced("2 seconds"),
547 }),
548 );
549 }
550
551 if (current === undefined || isGone(current)) {
552 return yield* new CustomDomainNotCreated({ domain, serviceId });
553 }
554
555 const observedPort = current.targetPort ?? undefined;
556 const desiredPort = props.targetPort;
557 if (desiredPort !== undefined && desiredPort !== observedPort) {
558 yield* customDomainUpdate({
559 environmentId: current.environmentId,
560 id: current.id,
561 targetPort: desiredPort,
562 });
563 current =
564 (yield* getById(current.id, current.projectId ?? projectId)) ??
565 current;
566 }
567
568 return toAttrs(current, { projectId, environmentId });
569 }),
570
571 delete: Effect.fn(function* ({ output }) {
572 const customDomainId = output.customDomainId;
573 const projectId = output.projectId;
574 if (customDomainId.length === 0) return;
575 yield* customDomainDelete(customDomainId).pipe(
576 Effect.catchTag("RailwayNotFound", () => Effect.void),
577 );
578 if (projectId.length > 0) {
579 yield* waitUntilGone(customDomainId, projectId);
580 }
581 }),
582 });