1import { Query } from "@distilled.cloud/core/query";
2import {
3 Railway,
4 type EdgeConfig,
5 type PurgeOnDeploy,
6 type UpdateServiceEdgeConfigInput,
7} from "@distilled.cloud/railway";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import { isResolved } from "../../Diff.ts";
12import * as Provider from "../../Provider.ts";
13import { Resource } from "../../Resource.ts";
14import type { Providers } from "../Providers.ts";
15
16type Ref<T> = T | Effect.Effect<T, never, Providers>;
17
18export type CdnService = {
19 readonly serviceId: string;
20};
21
22export type CdnEnvironment = {
23 readonly environmentId: string;
24};
25
26export interface CdnProps {
27 /**
28 * Parent Railway Service whose domains receive CDN caching.
29 */
30 service: Ref<CdnService>;
31 /**
32 * Environment the Service instance lives in.
33 */
34 environment: Ref<CdnEnvironment>;
35 /**
36 * HTML caching mode. `"AUTO"` caches HTML only when the origin sends
37 * `max-age` / `s-maxage`. `"FORCE"` uses the default TTL. `"NEVER"`
38 * skips HTML.
39 * @default "AUTO"
40 */
41 htmlCaching?: "AUTO" | "FORCE" | "NEVER" | (string & {});
42 /**
43 * What to purge after a successful deploy.
44 * @default "HTML"
45 */
46 purgeOnDeploy?: PurgeOnDeploy;
47 /**
48 * Fallback TTL in seconds when the origin sends no freshness.
49 * @default 7200
50 */
51 defaultTtlSeconds?: number;
52}
53
54export interface Cdn extends Resource<
55 "Railway.Website.Cdn",
56 CdnProps,
57 {
58 serviceId: string;
59 environmentId: string;
60 edgeConfigId: string;
61 enabled: boolean;
62 },
63 never,
64 Providers
65> {}
66
67/**
68 * Enable Railway's built-in CDN on a Website Service. Static assets
69 * (by Content-Type) are cached at the edge; HTML follows
70 * {@link CdnProps.htmlCaching}.
71 *
72 * @resource
73 */
74export const Cdn = Resource<Cdn>("Railway.Website.Cdn");
75
76export class CdnServiceMissing extends Data.TaggedError(
77 "Railway.Website.CdnServiceMissing",
78)<{
79 message: string;
80}> {}
81
82const serviceIdOf = (value: unknown): string | undefined => {
83 if (value === null || typeof value !== "object") return undefined;
84 const rec = value as { serviceId?: unknown };
85 return typeof rec.serviceId === "string" && rec.serviceId.length > 0
86 ? rec.serviceId
87 : undefined;
88};
89
90const environmentIdOf = (value: unknown): string | undefined => {
91 if (value === null || typeof value !== "object") return undefined;
92 const rec = value as { environmentId?: unknown };
93 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
94 ? rec.environmentId
95 : undefined;
96};
97
98const cachingInput = (props: CdnProps) => ({
99 htmlCaching: (props.htmlCaching ?? "AUTO").toLowerCase(),
100 purgeOnDeploy: props.purgeOnDeploy ?? "HTML",
101 defaultTtlSeconds: props.defaultTtlSeconds ?? 7200,
102});
103
104const edgeConfigFields = <E>(config: Query<EdgeConfig, E>) => ({
105 id: config.id,
106 enabled: config.enabled,
107 caching: config.caching.pipe(
108 Query.map((caching) => ({
109 mode: caching.mode,
110 htmlCaching: caching.htmlCaching,
111 purgeOnDeploy: caching.purgeOnDeploy,
112 defaultTtlSeconds: caching.defaultTtlSeconds,
113 })),
114 ),
115});
116
117const readEdgeConfig = Query.fn((serviceId: string, environmentId: string) =>
118 Railway.serviceInstance({ serviceId, environmentId }).edgeConfig.pipe(
119 Query.map(edgeConfigFields),
120 ),
121);
122
123const readEdgeConfigAndDomains = Query.fn(
124 (serviceId: string, environmentId: string) => {
125 const instance = Railway.serviceInstance({ serviceId, environmentId });
126 return {
127 edgeConfig: instance.edgeConfig.pipe(Query.map(edgeConfigFields)),
128 domains: {
129 serviceDomains: instance.domains.serviceDomains.pipe(
130 Query.map((domain) => ({ syncStatus: domain.syncStatus })),
131 ),
132 customDomains: instance.domains.customDomains.pipe(
133 Query.map((domain) => ({ syncStatus: domain.syncStatus })),
134 ),
135 },
136 };
137 },
138);
139
140const enableServiceCdn = Query.fn((serviceId: string, environmentId: string) =>
141 edgeConfigFields(
142 Railway.enableServiceCdn({ input: { environmentId, serviceId } }),
143 ),
144);
145
146const updateServiceEdgeConfig = Query.fn(
147 (input: UpdateServiceEdgeConfigInput) => ({
148 id: Railway.updateServiceEdgeConfig({ input }).id,
149 }),
150);
151
152const disableServiceCdn = Query.fn((serviceId: string, environmentId: string) =>
153 Railway.disableServiceCdn({ input: { environmentId, serviceId } }),
154);
155
156export class CdnPublicDomainPending extends Data.TaggedError(
157 "Railway.Website.CdnPublicDomainPending",
158)<{ serviceId: string; environmentId: string }> {}
159
160export class CdnConfigurationPending extends Data.TaggedError(
161 "Railway.Website.CdnConfigurationPending",
162)<{ serviceId: string; environmentId: string }> {}
163
164const getConfig = (serviceId: string, environmentId: string) =>
165 readEdgeConfig(serviceId, environmentId).pipe(
166 Effect.map((edgeConfig) => edgeConfig ?? undefined),
167 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
168 );
169
170const getReadyConfig = (serviceId: string, environmentId: string) =>
171 readEdgeConfigAndDomains(serviceId, environmentId).pipe(
172 Effect.flatMap((instance) =>
173 [
174 ...instance.domains.serviceDomains,
175 ...instance.domains.customDomains,
176 ].some(
177 (domain) =>
178 domain.syncStatus === "ACTIVE" || domain.syncStatus === "UNSPECIFIED",
179 )
180 ? Effect.succeed(instance.edgeConfig ?? undefined)
181 : Effect.fail(new CdnPublicDomainPending({ serviceId, environmentId })),
182 ),
183 Effect.retry({
184 while: (error) => error._tag === "Railway.Website.CdnPublicDomainPending",
185 times: 8,
186 schedule: Schedule.spaced("2 seconds"),
187 }),
188 );
189
190export const CdnProvider = () =>
191 Provider.succeed(Cdn, {
192 stables: ["serviceId", "environmentId", "edgeConfigId"],
193 nuke: { dependsOn: ["Railway.Service"] },
194
195 diff: Effect.fn(function* ({ news, output }) {
196 if (news === undefined || !isResolved(news) || output === undefined) {
197 return undefined;
198 }
199 const serviceId = serviceIdOf(news.service);
200 const environmentId = environmentIdOf(news.environment);
201 if (
202 (serviceId !== undefined && serviceId !== output.serviceId) ||
203 (environmentId !== undefined && environmentId !== output.environmentId)
204 ) {
205 return { action: "replace" as const, deleteFirst: true };
206 }
207 return undefined;
208 }),
209
210 list: () => Effect.succeed([]),
211
212 read: Effect.fn(function* ({ olds, output }) {
213 const serviceId = output?.serviceId ?? serviceIdOf(olds?.service);
214 const environmentId =
215 output?.environmentId ?? environmentIdOf(olds?.environment);
216 if (serviceId === undefined || environmentId === undefined)
217 return undefined;
218 const current = yield* getConfig(serviceId, environmentId);
219 return current === undefined
220 ? undefined
221 : {
222 serviceId,
223 environmentId,
224 edgeConfigId: current.id,
225 enabled:
226 current.enabled &&
227 current.caching != null &&
228 current.caching.mode.toLowerCase() !== "off",
229 };
230 }),
231
232 reconcile: Effect.fn(function* ({ news, output }) {
233 const serviceId = serviceIdOf(news.service) ?? output?.serviceId ?? "";
234 const environmentId =
235 environmentIdOf(news.environment) ?? output?.environmentId ?? "";
236 if (serviceId.length === 0 || environmentId.length === 0) {
237 return yield* new CdnServiceMissing({
238 message: "Railway.Website.Cdn requires a Service and environment.",
239 });
240 }
241 const config = { caching: cachingInput(news) };
242 let current = yield* getReadyConfig(serviceId, environmentId);
243 if (
244 current?.enabled !== true ||
245 current.caching == null ||
246 current.caching.mode.toLowerCase() === "off"
247 ) {
248 current = yield* enableServiceCdn(serviceId, environmentId);
249 }
250 const caching = current?.caching;
251 const changed =
252 caching?.htmlCaching !== config.caching.htmlCaching ||
253 caching?.purgeOnDeploy !== config.caching.purgeOnDeploy ||
254 caching?.defaultTtlSeconds !== config.caching.defaultTtlSeconds;
255 if (changed) {
256 yield* updateServiceEdgeConfig({ serviceId, environmentId, config });
257 }
258 const enabled = yield* getConfig(serviceId, environmentId).pipe(
259 Effect.flatMap((observed) =>
260 observed?.enabled === true &&
261 observed.caching != null &&
262 observed.caching.mode.toLowerCase() !== "off" &&
263 observed.caching.htmlCaching === config.caching.htmlCaching &&
264 observed.caching.purgeOnDeploy === config.caching.purgeOnDeploy &&
265 observed.caching.defaultTtlSeconds ===
266 config.caching.defaultTtlSeconds
267 ? Effect.succeed(observed)
268 : Effect.fail(
269 new CdnConfigurationPending({ serviceId, environmentId }),
270 ),
271 ),
272 Effect.retry({
273 while: (error) =>
274 error._tag === "Railway.Website.CdnConfigurationPending",
275 times: 8,
276 schedule: Schedule.spaced("1 second"),
277 }),
278 );
279 return {
280 serviceId,
281 environmentId,
282 edgeConfigId: enabled.id,
283 enabled: enabled.enabled,
284 };
285 }),
286
287 delete: Effect.fn(function* ({ output }) {
288 if (output === undefined) return;
289 const current = yield* getConfig(output.serviceId, output.environmentId);
290 if (current === undefined || !current.enabled) return;
291 yield* disableServiceCdn(output.serviceId, output.environmentId);
292 }),
293 });