1import {
2 waitUntilDeleted,
3 projectServices as fetchProjectServices,
4} from "./GraphQL.ts";
5import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
6import { Railway, type TCPProxy } from "@distilled.cloud/railway";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import { isResolved } from "../Diff.ts";
11import * as Provider from "../Provider.ts";
12import { Resource } from "../Resource.ts";
13import { matchesAlchemyPhysicalName } from "./Metadata.ts";
14import { ownedProjects, projectEnvironmentIds } from "./Project.ts";
15import type { Providers } from "./Providers.ts";
16
17const proxyFields = <E>(proxy: Query<TCPProxy, E>) => ({
18 id: proxy.id,
19 applicationPort: proxy.applicationPort,
20 deletedAt: proxy.deletedAt,
21 syncStatus: proxy.syncStatus,
22 domain: proxy.domain,
23 proxyPort: proxy.proxyPort,
24 serviceId: proxy.serviceId,
25 environmentId: proxy.environmentId,
26});
27type CloudProxy = UnwrapPlan<ReturnType<typeof proxyFields>>;
28
29const readTcpProxies = Query.fn((environmentId: string, serviceId: string) =>
30 Railway.tcpProxies({ environmentId, serviceId }).pipe(Query.map(proxyFields)),
31);
32
33const tcpProxyCreate = Query.fn(
34 (input: {
35 applicationPort: number;
36 environmentId: string;
37 serviceId: string;
38 }) => proxyFields(Railway.tcpProxyCreate({ input })),
39);
40
41const tcpProxyDelete = Query.fn((id: string) => Railway.tcpProxyDelete({ id }));
42
43/**
44 * A resource-valued prop: the resource itself, or an Effect that produces
45 * it (so `yield* Service(...)` and `Service(...)` both type-check).
46 */
47type Ref<T> = T | Effect.Effect<T, never, Providers>;
48
49/**
50 * Service identity a TCP proxy attaches to. Accepts a `Railway.Service`,
51 * `Railway.Postgres`, `Railway.Redis`, or a `{ serviceId }` stub.
52 */
53export type TcpProxyService = {
54 readonly serviceId: string;
55};
56
57/**
58 * Environment identity a TCP proxy is created in. Accepts a
59 * `Railway.Project` (its primary environment), a `Railway.Environment`, or
60 * an `{ environmentId }` stub.
61 */
62export type TcpProxyEnvironment = {
63 readonly environmentId: string;
64};
65
66export interface TcpProxyProps {
67 /**
68 * Service to expose. Accepts a `Railway.Service` or `{ serviceId }`.
69 * Mutually exclusive with `postgres` / `redis`. Changing it replaces
70 * the proxy.
71 */
72 service?: Ref<TcpProxyService>;
73 /**
74 * Postgres service to expose. Same identity as `service`. Changing it
75 * replaces the proxy.
76 */
77 postgres?: Ref<TcpProxyService>;
78 /**
79 * Redis service to expose. Same identity as `service`. Changing it
80 * replaces the proxy.
81 */
82 redis?: Ref<TcpProxyService>;
83 /**
84 * Environment the proxy is created in. Accepts a `Railway.Project`
85 * (primary environment), a `Railway.Environment`, or `{ environmentId }`.
86 * Changing it replaces the proxy.
87 */
88 environment: Ref<TcpProxyEnvironment>;
89 /**
90 * Internal application port to proxy to (for example `5432` for
91 * Postgres, `6379` for Redis). Changing it replaces the proxy.
92 */
93 applicationPort: number;
94}
95
96export type TcpProxy = Resource<
97 "Railway.TcpProxy",
98 TcpProxyProps,
99 {
100 /** Railway TCP proxy id. */
101 id: string;
102 /** Public proxy hostname (`*.proxy.rlwy.net`). */
103 domain: string;
104 /** Public proxy port. Pair with `domain` to reach the service. */
105 proxyPort: number;
106 /** Internal application port being proxied. */
107 applicationPort: number;
108 /** Service the proxy is attached to. */
109 serviceId: string;
110 /** Environment the proxy lives in. */
111 environmentId: string;
112 /** Observed Railway sync status (`ACTIVE`, `CREATING`, …). */
113 syncStatus: string;
114 },
115 never,
116 Providers
117>;
118
119/**
120 * A Railway.TcpProxy exposes a service over public TCP. Identity is the
121 * Railway proxy id. There is no in-place update — changing `service`,
122 * `postgres`, `redis`, `environment`, or `applicationPort` replaces the
123 * proxy.
124 *
125 * Needed so laptop tests and `ConnectPostgres` from outside the private
126 * network can reach Postgres/Redis.
127 *
128 * @see https://docs.railway.com/networking/tcp-proxy
129 *
130 * ### Postgres
131 * Attach a proxy to a Postgres service on `5432`. `domain` and
132 * `proxyPort` are the public endpoint (`{domain}:{proxyPort}`).
133 *
134 * **Example:** Public TCP for Postgres
135 * ```typescript
136 * const site = yield* Railway.Project("Site");
137 * const db = yield* Railway.Postgres("Db", { project: site });
138 * const proxy = yield* Railway.TcpProxy("DbProxy", {
139 * postgres: db,
140 * environment: site,
141 * applicationPort: 5432,
142 * });
143 * ```
144 *
145 * :::caution[Changing `applicationPort` replaces the proxy]
146 * Railway has no TCP-proxy update API. A new proxy is created, then the
147 * old one is deleted.
148 * :::
149 *
150 * ### Redis
151 * Same shape on `6379`.
152 *
153 * **Example:** Public TCP for Redis
154 * ```typescript
155 * const site = yield* Railway.Project("Site");
156 * const cache = yield* Railway.Redis("Cache", { project: site });
157 * const proxy = yield* Railway.TcpProxy("CacheProxy", {
158 * redis: cache,
159 * environment: site,
160 * applicationPort: 6379,
161 * });
162 * ```
163 *
164 * ### Any service
165 * Pass a `Railway.Service` or a `{ serviceId }` stub.
166 *
167 * **Example:** Stub service id
168 * ```typescript
169 * const site = yield* Railway.Project("Site");
170 * const proxy = yield* Railway.TcpProxy("ApiProxy", {
171 * service: { serviceId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" },
172 * environment: site,
173 * applicationPort: 8080,
174 * });
175 * ```
176 *
177 * :::caution[Changing `service` or `environment` replaces the proxy]
178 * The proxy is created on the new service/environment. The old proxy is
179 * deleted.
180 * :::
181 *
182 * ### Module-scope declarations
183 * Resource-valued props accept the resource or an Effect producing it.
184 *
185 * **Example:** Module-scope Project and proxy
186 * ```typescript
187 * // src/db.ts
188 * import * as Railway from "alchemy/Railway";
189 *
190 * export const Site = Railway.Project("Site");
191 * export const Db = Railway.Postgres("Db", { project: Site });
192 * export const DbProxy = Railway.TcpProxy("DbProxy", {
193 * postgres: Db,
194 * environment: Site,
195 * applicationPort: 5432,
196 * });
197 * ```
198 *
199 * @resource
200 * @product Networking
201 */
202export const TcpProxy = Resource<TcpProxy>("Railway.TcpProxy");
203
204export class TcpProxyNotCreated extends Data.TaggedError(
205 "Railway.TcpProxyNotCreated",
206)<{
207 serviceId: string;
208 environmentId: string;
209 applicationPort: number;
210}> {}
211
212export class TcpProxyTargetMissing extends Data.TaggedError(
213 "Railway.TcpProxyTargetMissing",
214)<{
215 message: string;
216}> {}
217
218const isGone = (proxy: CloudProxy | undefined) =>
219 proxy === undefined ||
220 proxy.deletedAt != null ||
221 proxy.syncStatus === "DELETED";
222
223const normalizeDomain = (domain: string) => domain.replace(/\.+$/, "");
224
225const toAttrs = (proxy: CloudProxy): TcpProxy["Attributes"] => ({
226 id: proxy.id,
227 domain: normalizeDomain(proxy.domain),
228 proxyPort: proxy.proxyPort,
229 applicationPort: proxy.applicationPort,
230 serviceId: proxy.serviceId,
231 environmentId: proxy.environmentId,
232 syncStatus: proxy.syncStatus,
233});
234
235const serviceIdOf = (value: unknown): string | undefined => {
236 if (value === null || typeof value !== "object") return undefined;
237 const rec = value as { serviceId?: unknown };
238 if (typeof rec.serviceId === "string" && rec.serviceId.length > 0) {
239 return rec.serviceId;
240 }
241 return undefined;
242};
243
244const environmentIdOf = (value: unknown): string | undefined => {
245 if (value === null || typeof value !== "object") return undefined;
246 const rec = value as { environmentId?: unknown };
247 if (typeof rec.environmentId === "string" && rec.environmentId.length > 0) {
248 return rec.environmentId;
249 }
250 return undefined;
251};
252
253const targetOf = (
254 props: Pick<TcpProxyProps, "service" | "postgres" | "redis">,
255) => props.service ?? props.postgres ?? props.redis;
256
257const listProxies = (environmentId: string, serviceId: string) =>
258 readTcpProxies(environmentId, serviceId).pipe(
259 Effect.map((items) => items.filter((proxy) => !isGone(proxy))),
260 Effect.catchTag("RailwayNotFound", () =>
261 Effect.succeed([] as ReadonlyArray<CloudProxy>),
262 ),
263 );
264
265const findById = (environmentId: string, serviceId: string, id: string) =>
266 listProxies(environmentId, serviceId).pipe(
267 Effect.map((items) => items.find((proxy) => proxy.id === id)),
268 );
269
270const findByPort = (
271 environmentId: string,
272 serviceId: string,
273 applicationPort: number,
274) =>
275 listProxies(environmentId, serviceId).pipe(
276 Effect.map((items) =>
277 items.find((proxy) => proxy.applicationPort === applicationPort),
278 ),
279 );
280
281const observe = Effect.fn(function* (input: {
282 environmentId: string;
283 serviceId: string;
284 applicationPort?: number;
285 id?: string;
286}) {
287 if (input.id !== undefined && input.id.length > 0) {
288 const byId = yield* findById(
289 input.environmentId,
290 input.serviceId,
291 input.id,
292 );
293 if (byId !== undefined) return byId;
294 }
295 if (input.applicationPort !== undefined) {
296 return yield* findByPort(
297 input.environmentId,
298 input.serviceId,
299 input.applicationPort,
300 );
301 }
302 return undefined;
303});
304
305const waitUntilGone = (environmentId: string, serviceId: string, id: string) =>
306 waitUntilDeleted(
307 "TcpProxy",
308 id,
309 findById(environmentId, serviceId, id).pipe(
310 Effect.map((proxy) => proxy === undefined),
311 ),
312 );
313
314export const TcpProxyProvider = () =>
315 Provider.succeed(TcpProxy, {
316 stables: ["id", "domain", "proxyPort", "serviceId", "environmentId"],
317 nuke: { dependsOn: ["Railway.Project"] },
318
319 list: Effect.fn(function* () {
320 const projects = yield* ownedProjects();
321 const rows = yield* Effect.forEach(projects, (project) =>
322 Effect.gen(function* () {
323 const live = yield* fetchProjectServices(
324 project.projectId,
325 (service) => ({
326 id: service.id,
327 name: service.name,
328 deletedAt: service.deletedAt,
329 }),
330 ).pipe(
331 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
332 );
333 const services = (live ?? []).filter(
334 (service) =>
335 service.deletedAt == null &&
336 matchesAlchemyPhysicalName(service.name),
337 );
338 const envIds = yield* projectEnvironmentIds(project);
339 const nested = yield* Effect.forEach(services, (service) =>
340 Effect.forEach(envIds, (environmentId) =>
341 listProxies(environmentId, service.id).pipe(
342 Effect.map((proxies) => proxies.map((proxy) => toAttrs(proxy))),
343 ),
344 ).pipe(Effect.map((rows) => rows.flat())),
345 );
346 return nested.flat();
347 }),
348 );
349 return rows.flat();
350 }),
351
352 diff: Effect.fn(function* ({ news, output }) {
353 if (news === undefined || !isResolved(news)) return undefined;
354 if (output === undefined) return undefined;
355 const serviceId = serviceIdOf(targetOf(news));
356 const environmentId = environmentIdOf(news.environment);
357 const serviceChanged =
358 serviceId !== undefined && serviceId !== output.serviceId;
359 const environmentChanged =
360 environmentId !== undefined && environmentId !== output.environmentId;
361 const portChanged = news.applicationPort !== output.applicationPort;
362 if (serviceChanged || environmentChanged || portChanged) {
363 return { action: "replace" as const };
364 }
365 return undefined;
366 }),
367
368 read: Effect.fn(function* ({ olds, output }) {
369 const serviceId = output?.serviceId ?? serviceIdOf(targetOf(olds ?? {}));
370 const environmentId =
371 output?.environmentId ?? environmentIdOf(olds?.environment);
372 const applicationPort = output?.applicationPort ?? olds?.applicationPort;
373 if (serviceId === undefined || environmentId === undefined) {
374 return undefined;
375 }
376 const found = yield* observe({
377 environmentId,
378 serviceId,
379 applicationPort,
380 id: output?.id,
381 });
382 if (found === undefined) return undefined;
383 return toAttrs(found);
384 }),
385
386 reconcile: Effect.fn(function* ({ news, output }) {
387 const props = news ?? ({} as TcpProxyProps);
388 const serviceId = serviceIdOf(targetOf(props)) ?? output?.serviceId;
389 const environmentId =
390 environmentIdOf(props.environment) ?? output?.environmentId;
391 const applicationPort = props.applicationPort ?? output?.applicationPort;
392 if (serviceId === undefined || environmentId === undefined) {
393 return yield* new TcpProxyTargetMissing({
394 message:
395 "TcpProxy requires a service (or postgres/redis) and an environment",
396 });
397 }
398 if (applicationPort === undefined) {
399 return yield* new TcpProxyNotCreated({
400 serviceId,
401 environmentId,
402 applicationPort: 0,
403 });
404 }
405
406 let current = yield* observe({
407 environmentId,
408 serviceId,
409 applicationPort,
410 id: output?.id,
411 });
412
413 if (current === undefined) {
414 const created = yield* tcpProxyCreate({
415 applicationPort,
416 environmentId,
417 serviceId,
418 }).pipe(
419 Effect.catchTag("RailwayValidationError", () =>
420 Effect.succeed(undefined),
421 ),
422 );
423 current =
424 created !== undefined && !isGone(created)
425 ? created
426 : yield* observe({
427 environmentId,
428 serviceId,
429 applicationPort,
430 });
431 }
432
433 if (current === undefined || isGone(current)) {
434 return yield* new TcpProxyNotCreated({
435 serviceId,
436 environmentId,
437 applicationPort,
438 });
439 }
440
441 return toAttrs(current);
442 }),
443
444 delete: Effect.fn(function* ({ output }) {
445 const id = output.id;
446 if (id.length === 0) return;
447 yield* tcpProxyDelete(id).pipe(
448 // Railway serializes proxy mutations per environment: a delete
449 // racing an in-flight deploy fails with "Cannot delete TCP proxy:
450 // an operation is already in progress".
451 Effect.retry({
452 while: (e) => e._tag === "RailwayOperationInProgress",
453 schedule: Schedule.spaced("3 seconds"),
454 times: 10,
455 }),
456 Effect.catchTag("RailwayNotFound", () => Effect.void),
457 );
458 if (output.environmentId.length > 0 && output.serviceId.length > 0) {
459 yield* waitUntilGone(output.environmentId, output.serviceId, id);
460 }
461 }),
462 });