1import type { Builder, RestartPolicyType } from "@distilled.cloud/railway";
2import * as Effect from "effect/Effect";
3import type * as Redacted from "effect/Redacted";
4import {
5 Platform,
6 type Main,
7 type MainRpc,
8 type PlatformProps,
9} from "../Platform.ts";
10import type { HttpEffect } from "../Http.ts";
11import type { Resource } from "../Resource.ts";
12import type { ServerHost } from "../Server/Process.ts";
13import type { ServiceBinding } from "./MountVolume.ts";
14import type { Project } from "./Project.ts";
15import type { Providers } from "./Providers.ts";
16import {
17 createRailwayHostRuntimeContext,
18 type ExtraFile,
19 type RailwayBuildOptions,
20 type RailwayHostRuntimeContext,
21} from "./hosted.ts";
22import { serveRailwayRpc } from "./rpc-server.ts";
23import { mintRpcToken } from "./rpc-token.ts";
24
25/**
26 * A resource-valued prop: the resource itself, or an Effect that produces
27 * it (so `yield* Project(...)` and `Project(...)` both type-check).
28 */
29type Ref<T> = T | Effect.Effect<T, never, Providers>;
30
31export type { ExtraFile, RailwayBuildOptions };
32
33/**
34 * Environment identity a Service is deployed into. Accepts a
35 * `Railway.Project` (its primary environment), a `Railway.Environment`,
36 * or an `{ environmentId }` stub.
37 */
38export type ServiceEnvironment = {
39 readonly environmentId: string;
40};
41
42export interface ServiceProps extends PlatformProps {
43 /**
44 * Parent Railway Project. Accepts a `Railway.Project` or an Effect
45 * that produces one. Changing the Project replaces the Service.
46 */
47 project: Ref<Project>;
48 /**
49 * Environment to deploy the service instance into. Accepts a
50 * `Railway.Project` (primary environment), a `Railway.Environment`, or
51 * `{ environmentId }`. Defaults to the project's primary environment.
52 * Changing it replaces the Service.
53 */
54 environment?: Ref<ServiceEnvironment>;
55 /**
56 * Module entrypoint bundled with rolldown and packed into a generated
57 * Dockerfile. Railway builds that Dockerfile (`railway up`). Typically
58 * `import.meta.url`. Mutually exclusive with the public-image path
59 * (`image` without `main`). A content-hash change updates in place.
60 */
61 main?: string;
62 /**
63 * Local Docker context uploaded with Railway `up`. Paths are relative to the
64 * initial working directory. Limited to a 32 MiB archive, 10,000 entries,
65 * ASCII paths, and no symlinks; Docker ignore files apply.
66 */
67 context?: string;
68 /**
69 * Docker image Railway should run.
70 *
71 * When `main` is omitted this is `source.image` (e.g.
72 * `hashicorp/http-echo`). When `main` is set this is the generated
73 * Dockerfile's `FROM` (default `node:26-slim`).
74 */
75 image?: string;
76 /**
77 * Region the service runs in (`us-west2`, `europe-west4-drams3a`, …).
78 * Railway places replicas with `deploy.multiRegionConfig`. Omit this
79 * and the current placement is left alone (the workspace default on
80 * first create). Updating it moves the replicas in place and keeps
81 * the current replica count.
82 */
83 region?: string;
84 /**
85 * Port the process listens on. Written to `PORT` and used as the
86 * generated `*.up.railway.app` `targetPort`. Default is `3000` for
87 * Effect-native (`main`) Services. Pass `5678` for
88 * `hashicorp/http-echo`.
89 */
90 port?: number;
91 /**
92 * Whether to create and manage a generated `*.up.railway.app` domain.
93 * Existing unowned generated or custom domains are not removed.
94 * @default true
95 */
96 publicDomain?: boolean;
97 /**
98 * Additional environment variables. Merged after binding-injected
99 * `env`. Upserted as service-scoped Railway variables with
100 * `skipDeploys: true`; this Service owns the subsequent deploy.
101 */
102 env?: Record<string, any>;
103 /**
104 * Private-mesh RPC token. Minted by a child `Alchemy.Random`
105 * (`{logicalId}RpcToken`) and persisted in state. Set automatically.
106 */
107 rpcToken?: Redacted.Redacted<string>;
108 /**
109 * Bundler configuration for `main`: rolldown `input`/`output`
110 * overrides, plus `install` for packages that must ship as real
111 * `node_modules` (see {@link RailwayBuildOptions}).
112 */
113 build?: RailwayBuildOptions;
114 /**
115 * Extra files/directories copied into `/app` in the generated
116 * Dockerfile (`COPY dest /app/dest`). Website composites use this to
117 * bake the framework `clientDirectory` (or Next.js `.next`) so asset
118 * changes rebuild on Railway.
119 */
120 extraFiles?: ReadonlyArray<ExtraFile>;
121 /**
122 * Named export to load from `main`.
123 *
124 * @default "default"
125 */
126 handler?: string;
127 /**
128 * Service name. Unique per Project. If omitted, a unique name is
129 * generated from the stack, stage and logical ID. Changing it updates
130 * in place via `serviceUpdate`.
131 */
132 name?: string;
133 /**
134 * GitHub repository (`owner/repo`). Creates/syncs
135 * `serviceCreate.source.repo` / `serviceInstanceUpdate.source.repo`.
136 * Mutually exclusive with the public-image and `main` paths. Requires a
137 * GitHub connection on the Railway account.
138 */
139 repo?: string;
140 /**
141 * GitHub branch for {@link repo}. Passed as `serviceCreate.branch` and
142 * synced onto the deployment trigger.
143 */
144 branch?: string;
145 /**
146 * Monorepo subdirectory Railway builds from (`rootDirectory`).
147 */
148 rootDirectory?: string;
149 /**
150 * Build command (`pnpm build`). Distinct from hosted
151 * {@link RailwayBuildOptions} (`build.install`).
152 */
153 buildCommand?: string;
154 /**
155 * Pre-deploy step Railway runs after the image build and before
156 * start (migrations, seed). Omit to leave the current Railway setting
157 * unchanged. Pass `{ command: null }` to clear it.
158 *
159 * @see https://docs.railway.com/deployments/pre-deploy-command
160 */
161 preDeploy?: {
162 /**
163 * Shell command Railway executes in a separate container after
164 * build and before start. Must exit 0 or the deployment fails.
165 * Pass `null` to clear it.
166 */
167 command: string | null;
168 };
169 /**
170 * Start command (`pnpm start`).
171 */
172 startCommand?: string;
173 /**
174 * HTTP healthcheck path (`/health`). Synced via
175 * `serviceInstanceUpdate.healthcheckPath`. Alias of {@link healthcheck}
176 * (Railway IaC `healthcheck: "/health"`).
177 */
178 healthcheckPath?: string;
179 /**
180 * HTTP healthcheck path. Same as {@link healthcheckPath}; matches
181 * Railway IaC `healthcheck`.
182 */
183 healthcheck?: string;
184 /**
185 * Healthcheck timeout in seconds.
186 */
187 healthcheckTimeout?: number;
188 /**
189 * Cron expression (`cronSchedule`). Observed as `cronSchedule` /
190 * `nextCronRunAt`.
191 */
192 cronSchedule?: string;
193 /**
194 * Restart policy (`ALWAYS`, `NEVER`, `ON_FAILURE`).
195 */
196 restartPolicyType?: RestartPolicyType;
197 /**
198 * Max retries when {@link restartPolicyType} is `ON_FAILURE`.
199 */
200 restartPolicyMaxRetries?: number;
201 /**
202 * Drain window in seconds before a replica is stopped.
203 */
204 drainingSeconds?: number;
205 /**
206 * Overlap window in seconds while a new replica starts.
207 */
208 overlapSeconds?: number;
209 /**
210 * Sleep the application when idle.
211 */
212 sleepApplication?: boolean;
213 /**
214 * Auto-deploy on new commits / image tags
215 * (`serviceInstanceAutoDeployUpdate`).
216 */
217 autoUpdates?: boolean;
218 /**
219 * Dockerfile path relative to {@link rootDirectory}, or to {@link context}
220 * for a local context.
221 * @default "Dockerfile"
222 */
223 dockerfilePath?: string;
224 /**
225 * Builder (`RAILPACK`, `NIXPACKS`, `HEROKU`, `PAKETO`).
226 */
227 builder?: Builder;
228 /**
229 * Git watch patterns that trigger a rebuild.
230 */
231 watchPatterns?: string[];
232}
233
234export type Service = Resource<
235 "Railway.Service",
236 ServiceProps,
237 {
238 /** Railway service id. */
239 serviceId: string;
240 /** Physical service name (unique per project). */
241 name: string;
242 /** Parent Railway project id. */
243 projectId: string;
244 /** Environment the instance is deployed in. */
245 environmentId: string;
246 /** Observed `source.image`, if set. */
247 image: string | undefined;
248 /** Observed `source.repo` (`owner/repo`), if set. */
249 repo: string | undefined;
250 /** Observed healthcheck path. */
251 healthcheckPath: string | undefined;
252 /** Observed healthcheck timeout in seconds. */
253 healthcheckTimeout: number | undefined;
254 /**
255 * Observed replica count. Alchemy does not set this — Railway
256 * load-balances whatever is running (default 1).
257 */
258 replicas: number | undefined;
259 /** Observed build command. */
260 buildCommand: string | undefined;
261 /** Observed start command. */
262 startCommand: string | undefined;
263 /** Observed cron schedule. */
264 cronSchedule: string | undefined;
265 /** Observed root directory. */
266 rootDirectory: string | undefined;
267 /**
268 * Region the service is placed in. Set when
269 * `deploy.multiRegionConfig` has replicas in exactly one region.
270 */
271 region: string | undefined;
272 /** Port published on the generated service domain. */
273 port: number | undefined;
274 /** Public `https://{domain}` URL (`*.up.railway.app`). */
275 url: string | undefined;
276 /** Generated Railway service domain hostname. */
277 domain: string | undefined;
278 /**
279 * Internal DNS name on the default private mesh
280 * (`{name}.railway.internal`). Derived from the service name — no
281 * extra API call.
282 */
283 dnsName: string;
284 /**
285 * Shared token for private schemaless RPC. Value of the child
286 * `Alchemy.Random` resource. Packed onto callers by `bindService` /
287 * `bindFunction`; never send this to the public internet.
288 */
289 rpcToken: string;
290 /** Railway service domain id. */
291 domainId: string | undefined;
292 /** Latest deployment id, if one exists. */
293 deploymentId: string | undefined;
294 /** Latest deployment status (`SUCCESS`, `DEPLOYING`, …). */
295 deploymentStatus: string | undefined;
296 /** Content hash of the bundled program (empty for public images). */
297 code: {
298 hash: string;
299 };
300 },
301 ServiceBinding,
302 Providers
303>;
304
305export const isService = (value: unknown): value is Service =>
306 typeof value === "object" &&
307 value !== null &&
308 (value as { Type?: string }).Type === "Railway.Service";
309
310export type ServiceServices = ServerHost;
311
312export type ServiceShape = Main<ServiceServices> & MainRpc<ServiceServices>;
313
314export type ServiceRuntimeContext = RailwayHostRuntimeContext;
315
316const createServiceRuntimeContext = (id: string): ServiceRuntimeContext => {
317 const base = createRailwayHostRuntimeContext("Railway.Service")(id);
318 const inner = base.serve;
319 return Object.assign(base, {
320 serve: ((handler, options) =>
321 inner(
322 (options?.shape === undefined
323 ? handler
324 : serveRailwayRpc(
325 options.shape,
326 handler as HttpEffect,
327 )) as typeof handler,
328 options,
329 )) as ServiceRuntimeContext["serve"],
330 });
331};
332
333/**
334 * A Railway.Service is a container in a Project. Point it at a public
335 * image (`hashicorp/http-echo`) or an Effect program (`main`). Alchemy
336 * stamps the name, creates a `*.up.railway.app` domain via
337 * `serviceDomainCreate`, and deploys.
338 *
339 * @see https://docs.railway.com/guides/services
340 *
341 * ### Image service
342 * Pass `image` without `main`. Railway pulls the image and runs it.
343 * `url` is the generated `*.up.railway.app` hostname.
344 *
345 * **Example:** Public image
346 * ```typescript
347 * const site = yield* Railway.Project("Site");
348 * const api = yield* Railway.Service("Api", {
349 * project: site,
350 * image: "hashicorp/http-echo",
351 * port: 5678,
352 * });
353 * ```
354 *
355 * :::caution[Changing `project` replaces the Service]
356 * The Service is created in the new Project. The old Service is deleted.
357 * :::
358 *
359 * ### Effect-native Service
360 * A Service is a class. `main: import.meta.url` is the bundle
361 * entrypoint. Alchemy bundles this file with Rolldown, generates a
362 * Dockerfile (`FROM node:26-slim`), and uploads the context. Railway
363 * builds the image. `build.install: ["pg"]` ships `pg` unbundled.
364 *
365 * **Example:** Class + Project + main
366 * ```typescript
367 * export default class Api extends Railway.Service<Api>()(
368 * "Api",
369 * {
370 * project: Site,
371 * main: import.meta.url,
372 * build: { install: ["pg"] },
373 * },
374 * Effect.gen(function* () {
375 * return {
376 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
377 * };
378 * }),
379 * ) {}
380 * ```
381 *
382 * ### Local Docker context
383 * `context` is a directory Railway builds with `up`. Mutually exclusive
384 * with `image` (without `main`) and `repo`. Docker ignore files apply.
385 *
386 * **Example:** Upload a local Dockerfile
387 * ```typescript
388 * const api = yield* Railway.Service("Api", {
389 * project: site,
390 * context: "./api",
391 * port: 80,
392 * });
393 * ```
394 *
395 * ### The public URL
396 * Yield the Service in the Stack. `api.url` is
397 * `https://{name}.up.railway.app`.
398 *
399 * **Example:** Stack output
400 * ```typescript
401 * export default Alchemy.Stack(
402 * "MyApp",
403 * { providers: Railway.providers(), state: Alchemy.localState() },
404 * Effect.gen(function* () {
405 * const api = yield* Api;
406 * return { url: api.url };
407 * }),
408 * );
409 * ```
410 *
411 * ### Private service
412 * `publicDomain: false` skips the generated `*.up.railway.app` hostname.
413 * `url` / `domain` stay unset. Reach it on the private mesh at
414 * `{name}.railway.internal`. Unowned generated or custom domains are
415 * left alone.
416 *
417 * **Example:** Private-only service
418 * ```typescript
419 * const worker = yield* Railway.Service("Worker", {
420 * project: site,
421 * image: "hashicorp/http-echo",
422 * port: 5678,
423 * publicDomain: false,
424 * });
425 * ```
426 *
427 * ### Pin a region
428 * Omit `region` to leave placement alone. On first create that is the
429 * workspace default. Updating `region` moves the replicas in place.
430 *
431 * **Example:** Region
432 * ```typescript
433 * const api = yield* Railway.Service("Api", {
434 * project: site,
435 * image: "hashicorp/http-echo",
436 * port: 5678,
437 * region: "us-west2",
438 * });
439 * ```
440 *
441 * ### Healthcheck
442 * `healthcheckPath` (or `healthcheck`, matching Railway IaC) is the
443 * HTTP path Railway probes. Railway load-balances public traffic
444 * across whatever replicas are running. Alchemy does not pin a count.
445 *
446 * **Example:** Healthcheck
447 * ```typescript
448 * const api = yield* Railway.Service("Api", {
449 * project: site,
450 * image: "hashicorp/http-echo",
451 * port: 5678,
452 * healthcheck: "/health",
453 * });
454 * ```
455 *
456 * :::caution[One volume per service]
457 * Railway does not give each replica its own disk. A second volume
458 * on one Service fails with `Railway.MultipleVolumes`.
459 * :::
460 *
461 * ### GitHub source
462 * `repo` + `branch` is the third source, next to `image` and `main`.
463 * Railway must have GitHub connected to the account.
464 *
465 * **Example:** GitHub repo
466 * ```typescript
467 * const api = yield* Railway.Service("Api", {
468 * project: site,
469 * repo: "acme/web",
470 * branch: "main",
471 * rootDirectory: "apps/api",
472 * buildCommand: "pnpm build",
473 * startCommand: "pnpm start",
474 * });
475 * ```
476 *
477 * ### Pre-deploy
478 * Railway runs `preDeploy.command` after the image build and before
479 * start — the same setting as the dashboard Pre-deploy Command.
480 *
481 * **Example:** Run migrations before traffic
482 * ```typescript
483 * const api = yield* Railway.Service("Api", {
484 * project: site,
485 * image: "hashicorp/http-echo",
486 * preDeploy: { command: "bun --cwd apps/api migrate" },
487 * });
488 * ```
489 *
490 * ### Cron
491 * `cronSchedule` runs the service on a cron expression.
492 *
493 * **Example:** Cron schedule
494 * ```typescript
495 * const worker = yield* Railway.Service("Worker", {
496 * project: site,
497 * image: "hashicorp/http-echo",
498 * cronSchedule: "0 * * * *",
499 * });
500 * ```
501 *
502 * ### Mount a disk
503 * Bind {@link MountVolume} inside init. Provide {@link MountVolumeLive}.
504 *
505 * **Example:** Volume
506 * ```typescript
507 * export default class Api extends Railway.Service<Api>()(
508 * "Api",
509 * { project: Site, image: "hashicorp/http-echo", port: 5678 },
510 * Effect.gen(function* () {
511 * const disk = yield* Railway.MountVolume(Data, { path: "/data" });
512 * return {
513 * fetch: Effect.succeed(HttpServerResponse.text(disk.path)),
514 * };
515 * }).pipe(Effect.provide(Railway.MountVolumeLive)),
516 * ) {}
517 * ```
518 *
519 * ### Schemaless RPC
520 * Return methods next to `fetch`. Another Service or Function binds
521 * this class and calls them over `{name}.railway.internal` with a
522 * shared token. Public `*.up.railway.app` requests to `/__rpc__/*`
523 * get 401.
524 *
525 * **Example:** Bind a Service
526 * ```typescript
527 * export default class Query extends Railway.Service<Query>()(
528 * "Query",
529 * { project: Site, main: import.meta.url },
530 * Effect.gen(function* () {
531 * return {
532 * greet: (name: string) => Effect.succeed(`hello ${name}`),
533 * };
534 * }),
535 * ) {}
536 *
537 * export default class Api extends Railway.Function<Api>()(
538 * "Api",
539 * { project: Site, main: import.meta.url },
540 * Effect.gen(function* () {
541 * const query = yield* Railway.bindService(Query);
542 * return {
543 * fetch: query
544 * .greet("sam")
545 * .pipe(Effect.map((greeting) => HttpServerResponse.text(greeting))),
546 * };
547 * }),
548 * ) {}
549 * ```
550 *
551 * ### Module-scope declarations
552 * Declare the Project once. Pass it into every child. Resource-valued
553 * props accept the resource or an Effect producing it.
554 *
555 * **Example:** Module-scope Service
556 * ```typescript
557 * // src/api.ts
558 * import * as Railway from "alchemy/Railway";
559 *
560 * export const Site = Railway.Project("Site");
561 * export const Api = Railway.Service("Api", {
562 * project: Site,
563 * image: "hashicorp/http-echo",
564 * port: 5678,
565 * });
566 * ```
567 *
568 * @resource
569 * @product Service
570 */
571export const Service: Platform<
572 Service,
573 ServiceServices,
574 ServiceShape,
575 ServiceRuntimeContext
576> = Platform("Railway.Service", {
577 createRuntimeContext: createServiceRuntimeContext,
578 transformProps: (id, props) =>
579 Effect.gen(function* () {
580 if (globalThis.__ALCHEMY_RUNTIME__) return props;
581 const project = Effect.isEffect(props.project)
582 ? yield* props.project as Effect.Effect<Project, never, Providers>
583 : props.project;
584 const environment =
585 props.environment === undefined
586 ? undefined
587 : Effect.isEffect(props.environment)
588 ? yield* props.environment as Effect.Effect<
589 ServiceEnvironment,
590 never,
591 Providers
592 >
593 : props.environment;
594 const rpcToken = yield* mintRpcToken(id);
595 return { ...props, project, environment, rpcToken };
596 }),
597});