1import {
2 environmentServiceInstances,
3 waitUntilDeleted,
4 projectServices as fetchProjectServices,
5} from "./GraphQL.ts";
6import { createHash } from "node:crypto";
7import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
8import {
9 Railway,
10 type Service as RailwayService,
11 type ServiceCreateInput,
12 type ServiceInstance as RailwayServiceInstance,
13 type ServiceInstanceUpdateInput,
14 type VariableUpsertInput,
15} from "@distilled.cloud/railway";
16import * as Data from "effect/Data";
17import * as Effect from "effect/Effect";
18import type * as Redacted from "effect/Redacted";
19import * as FileSystem from "effect/FileSystem";
20import * as Schedule from "effect/Schedule";
21import type * as Types from "effect/Types";
22import { Unowned } from "../AdoptPolicy.ts";
23import * as Bundle from "../Bundle/Bundle.ts";
24import { isResolved, stripEffects } from "../Diff.ts";
25import type { InputProps } from "../Input.ts";
26import {
27 Platform,
28 type Main,
29 type MainRpc,
30 type PlatformProps,
31} from "../Platform.ts";
32import * as Provider from "../Provider.ts";
33import type { Resource } from "../Resource.ts";
34import type { ServerHost } from "../Server/Process.ts";
35import { Stack } from "../Stack.ts";
36import { createRailwayName, matchesAlchemyPhysicalName } from "./Metadata.ts";
37import { readServiceRegion, syncServiceRegion } from "./ServiceRegion.ts";
38import {
39 assertHostDisk,
40 type MountSpec,
41 type ServiceBinding,
42} from "./MountVolume.ts";
43import { attachVolumeToService } from "./Volume.ts";
44import {
45 ownedProjects,
46 projectEnvironmentIds,
47 type Project,
48} from "./Project.ts";
49import type { Providers } from "./Providers.ts";
50import {
51 ensureServiceDomain,
52 type ServiceDomainRecord,
53} from "./ServiceDomain.ts";
54import {
55 collectBindingState,
56 createRailwayFunctionSupport,
57 createRailwayFunctionRuntimeContext,
58 DEFAULT_PORT,
59 plainEnvValue,
60 toEnvRecord,
61 type HostedProgramProps,
62 type RailwayBuildOptions,
63 type RailwayHostRuntimeContext,
64} from "./hosted.ts";
65import { mintRpcToken, RPC_TOKEN_ENV } from "./rpc-token.ts";
66
67const serviceFields = <E>(service: Query<RailwayService, E>) => ({
68 id: service.id,
69 name: service.name,
70 deletedAt: service.deletedAt,
71});
72type CloudService = UnwrapPlan<ReturnType<typeof serviceFields>>;
73
74const attributeInstanceFields = <E>(
75 instance: Query<RailwayServiceInstance, E>,
76) => ({
77 source: instance.source.pipe(Query.map((s) => ({ image: s.image }))),
78 region: instance.region,
79 numReplicas: instance.numReplicas,
80 sleepApplication: instance.sleepApplication,
81 latestDeployment: instance.latestDeployment.pipe(
82 Query.map((d) => ({ id: d.id, status: d.status })),
83 ),
84 cronSchedule: instance.cronSchedule,
85 nextCronRunAt: instance.nextCronRunAt,
86});
87const instanceFields = <E>(instance: Query<RailwayServiceInstance, E>) => ({
88 ...attributeInstanceFields(instance),
89 deletedAt: instance.deletedAt,
90 startCommand: instance.startCommand,
91});
92type AttributeInstance = UnwrapPlan<ReturnType<typeof attributeInstanceFields>>;
93type ServiceInstanceResponse = UnwrapPlan<ReturnType<typeof instanceFields>>;
94
95export { FunctionBundleNotSingleFile } from "./hosted.ts";
96export type { InferEnv } from "./InferEnv.ts";
97
98/**
99 * A resource-valued prop: the resource itself, or an Effect that produces
100 * it (so `yield* Project(...)` and `Project(...)` both type-check).
101 */
102type Ref<T> = T | Effect.Effect<T, never, Providers>;
103
104/**
105 * Environment identity a Function is deployed into. Accepts a
106 * `Railway.Project` (its primary environment), a `Railway.Environment`,
107 * or an `{ environmentId }` stub.
108 */
109export type FunctionEnvironment = {
110 readonly environmentId: string;
111};
112
113/** Canvas Functions run on the Bun function runtime. */
114export const FUNCTION_RUNTIME_NAME = "bun" as const;
115
116/** Start-command prefix the Bun function runtime uses to decode source. */
117export const FUNCTION_START_PREFIX = "./run.sh ";
118
119/**
120 * Canvas Functions are capped at 96KB (the encoded start command, which
121 * includes `./run.sh ` plus standard base64 of the TypeScript file).
122 */
123export const FUNCTION_MAX_BYTES = 96 * 1024;
124
125/**
126 * Railway function-runtime images (`ghcr.io/railwayapp/function-*`).
127 * Canvas Functions are ordinary Services whose `source.image` uses this
128 * prefix — the same check the Railway CLI uses.
129 */
130export const isFunctionImage = (image: string | null | undefined): boolean =>
131 image != null && image.startsWith("ghcr.io/railwayapp/function");
132
133export interface FunctionProps<
134 Env extends Record<string, any> = Record<string, any>,
135> extends PlatformProps {
136 /**
137 * Parent Railway Project. Accepts a `Railway.Project` or an Effect
138 * that produces one. Changing the Project replaces the Function.
139 */
140 project: Ref<Project>;
141 /**
142 * Environment to deploy the function instance into. Accepts a
143 * `Railway.Project` (primary environment), a `Railway.Environment`, or
144 * `{ environmentId }`. Defaults to the project's primary environment.
145 * Changing it replaces the Function.
146 */
147 environment?: Ref<FunctionEnvironment>;
148 /**
149 * Service name. Unique per Project. If omitted, a unique name is
150 * generated from the stack, stage and logical ID. Changing it updates
151 * in place via `serviceUpdate`.
152 */
153 name?: string;
154 /**
155 * Module entrypoint bundled with rolldown into a **single file** and
156 * deployed as a canvas Function. Typically `import.meta.url`. No
157 * Docker. No registry — that is {@link Service}. Mutually exclusive
158 * with {@link source} / {@link path}.
159 */
160 main?: string;
161 /**
162 * Named export to load from {@link main}.
163 *
164 * @default "default"
165 */
166 handler?: string;
167 /**
168 * Bundler configuration for {@link main}: rolldown `input`/`output`
169 * overrides, plus `install` for packages Railway should `bun install`
170 * from the function's imports (`pg`).
171 */
172 build?: RailwayBuildOptions;
173 /**
174 * Inline TypeScript source (one file). Mutually preferred over
175 * {@link path}. Max 96KB once base64-encoded into the start command.
176 */
177 source?: string;
178 /**
179 * Path to a single TypeScript file (`railway functions new --path`).
180 * Read at plan/reconcile time. Distinct from Effect-native
181 * {@link main}, which bundles first, and from `Service({ main })`,
182 * which pushes a Docker image.
183 */
184 path?: string;
185 /**
186 * Cron expression (`cronSchedule`). Observed as `cronSchedule` /
187 * `nextCronRunAt`. `railway functions new --cron`.
188 */
189 cronSchedule?: string;
190 /**
191 * Create a generated `*.up.railway.app` domain (`--http`). Defaults
192 * to `true` when {@link cronSchedule} is omitted, `false` otherwise.
193 */
194 http?: boolean;
195 /**
196 * Sleep the application when idle (`--serverless` /
197 * `sleepApplication`). Updates in place.
198 */
199 sleepApplication?: boolean;
200 /**
201 * Region the function runs in (`us-west2`, `europe-west4-drams3a`, …).
202 * Railway places replicas with `deploy.multiRegionConfig`. Omit this
203 * and the current placement is left alone (the workspace default on
204 * first create). Updating it moves the replicas in place and keeps
205 * the current replica count.
206 */
207 region?: string;
208 /**
209 * Port published on the generated service domain. Passed as
210 * `serviceDomainCreate.targetPort`. Omit to let Railway detect it.
211 */
212 port?: number;
213 /**
214 * Additional environment variables. Merged after binding-injected
215 * `env`. Upserted as service-scoped Railway variables with
216 * `skipDeploys: true`; this Function owns the subsequent deploy.
217 *
218 * On an async Function, {@link InferEnv} types the handler's second
219 * argument from these keys. Railway has no native bindings — every
220 * value is a `string` at runtime (`process.env`).
221 */
222 env?: Env;
223 /**
224 * Private-mesh RPC token. Minted by a child `Alchemy.Random`
225 * (`{logicalId}RpcToken`) and persisted in state. Set automatically.
226 */
227 rpcToken?: Redacted.Redacted<string>;
228}
229
230export type Function<Env extends Record<string, any> = Record<string, any>> =
231 Resource<
232 "Railway.Function",
233 FunctionProps<Env>,
234 {
235 /** Railway service id. */
236 serviceId: string;
237 /** Physical service name (unique per project). */
238 name: string;
239 /** Parent Railway project id. */
240 projectId: string;
241 /** Environment the instance is deployed in. */
242 environmentId: string;
243 /** Observed function-runtime image. */
244 image: string;
245 /** Function runtime name (`bun`). */
246 runtime: typeof FUNCTION_RUNTIME_NAME;
247 /** Observed cron schedule, if set. */
248 cronSchedule: string | undefined;
249 /** Observed `sleepApplication`. */
250 sleepApplication: boolean | undefined;
251 /**
252 * Region the function is placed in. Set when
253 * `deploy.multiRegionConfig` has replicas in exactly one region.
254 */
255 region: string | undefined;
256 /** Port published on the generated service domain. */
257 port: number | undefined;
258 /**
259 * Internal DNS name on the default private mesh
260 * (`{name}.railway.internal`). Derived from the service name.
261 */
262 dnsName: string;
263 /**
264 * Shared token for private schemaless RPC. Value of the child
265 * `Alchemy.Random` resource. Packed onto callers by
266 * {@link bindFunction}; never send this to the public internet.
267 */
268 rpcToken: string;
269 /** Public `https://{domain}` URL (`*.up.railway.app`). */
270 url: string | undefined;
271 /** Generated Railway service domain hostname. */
272 domain: string | undefined;
273 /** Railway service domain id. */
274 domainId: string | undefined;
275 /** Latest deployment id, if one exists. */
276 deploymentId: string | undefined;
277 /** Latest deployment status (`SUCCESS`, `DEPLOYING`, …). */
278 deploymentStatus: string | undefined;
279 /** Next cron fire time, if scheduled. */
280 nextCronRunAt: string | undefined;
281 /** Content hash of the TypeScript source. */
282 code: {
283 hash: string;
284 };
285 },
286 ServiceBinding,
287 Providers
288 >;
289
290export const isFunction = (value: unknown): value is Function =>
291 typeof value === "object" &&
292 value !== null &&
293 (value as { Type?: string }).Type === "Railway.Function";
294
295export type FunctionServices = ServerHost;
296
297export type FunctionShape = Main<FunctionServices> & MainRpc<FunctionServices>;
298
299export type FunctionRuntimeContext = RailwayHostRuntimeContext;
300
301const resolveFunctionProps = (
302 id: string,
303 props: FunctionProps | Effect.Effect<FunctionProps, never, Providers>,
304) =>
305 Effect.gen(function* () {
306 const resolved = Effect.isEffect(props) ? yield* props : props;
307 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
308 const project = Effect.isEffect(resolved.project)
309 ? yield* resolved.project as Effect.Effect<Project, never, Providers>
310 : resolved.project;
311 const environment =
312 resolved.environment === undefined
313 ? undefined
314 : Effect.isEffect(resolved.environment)
315 ? yield* resolved.environment as Effect.Effect<
316 FunctionEnvironment,
317 never,
318 Providers
319 >
320 : resolved.environment;
321 const rpcToken = yield* mintRpcToken(id);
322 return { ...resolved, project, environment, rpcToken };
323 });
324
325/**
326 * A Railway.Function is a **single TypeScript file** on Railway's Bun
327 * canvas runtime. No GitHub repo. No Docker. No registry. Alchemy
328 * queries `functionRuntime(bun)`, creates the Service with that image,
329 * and writes the source as `startCommand` (`./run.sh` + base64).
330 *
331 * There are two ways to define a Function. Prefer **async** (`main` +
332 * `export default { fetch }`, no Effect) — Effect-native canvas
333 * Functions pull the Effect runtime into the 96KB encoded start-command
334 * cap and fail {@link FunctionTooLarge} for anything non-trivial. Use
335 * {@link Service} when you need an Effect program or a real image.
336 *
337 * Distinct from Effect-native {@link Service} (`main`), which bundles
338 * with Rolldown and uploads a generated Dockerfile for Railway to
339 * build.
340 *
341 * @see https://docs.railway.com/reference/functions
342 *
343 * ### Async Functions
344 * You don't have to use Effect. Declare the Function with `main`
345 * pointing at a file and **no** `Effect.gen` implementation — Alchemy
346 * bundles that file as-is (no Effect runtime) and deploys it as a
347 * canvas Function. The handler is a plain `async fetch`. Use the `env`
348 * prop to declare variables, and {@link InferEnv} to type the second
349 * argument (Railway bindings are environment variables).
350 *
351 * **Example:** Defining an async Function in your stack
352 * ```typescript
353 * const db = yield* Railway.Postgres("Db", { project: site });
354 *
355 * export const Ping = Railway.Function("Ping", {
356 * project: site,
357 * main: "./src/ping.ts",
358 * env: { DATABASE_URL: db.connectionUri },
359 * });
360 *
361 * export type PingEnv = Railway.InferEnv<typeof Ping>;
362 * ```
363 *
364 * **Example:** Writing the async handler
365 * ```typescript
366 * import type { PingEnv } from "../alchemy.run.ts";
367 *
368 * export default {
369 * async fetch(_request: Request, env: PingEnv) {
370 * return new Response(env.DATABASE_URL ? "ok" : "missing");
371 * },
372 * };
373 * ```
374 *
375 * ### Effect-native Function
376 * A Function is a class. Props describe the canvas Function. The Effect
377 * is the program that runs in it. `main: import.meta.url` is the bundle
378 * entrypoint — Alchemy bundles this file into one JS file and deploys
379 * it. No `registry`. Stay tiny: the encoded start command maxes out at
380 * 96KB (`effect` plus handlers overflows it).
381 *
382 * **Example:** Class + Project + main
383 * ```typescript
384 * export default class Ping extends Railway.Function<Ping>()(
385 * "Ping",
386 * {
387 * project: Site,
388 * main: import.meta.url,
389 * },
390 * Effect.gen(function* () {
391 * return {
392 * fetch: Effect.succeed(HttpServerResponse.text("ok")),
393 * };
394 * }),
395 * ) {}
396 * ```
397 *
398 * :::caution[Changing `project` replaces the Function]
399 * The Function is created in the new Project. The old Function is deleted.
400 * :::
401 *
402 * ### Inline source
403 * Pass inline `source` (or `path` to a `.ts` file) instead of `main`
404 * when the Function is not an Effect class. Alchemy generates a
405 * `*.up.railway.app` domain unless `http: false` or a cron schedule is
406 * set.
407 *
408 * **Example:** Inline source
409 * ```typescript
410 * const site = yield* Railway.Project("Site");
411 * const ping = yield* Railway.Function("Ping", {
412 * project: site,
413 * source: `
414 * Bun.serve({
415 * hostname: "0.0.0.0",
416 * port: Number(process.env.PORT ?? 3000),
417 * fetch() { return new Response("ok"); },
418 * });
419 * `,
420 * });
421 * ```
422 *
423 * ### File path
424 * `path` is the CLI `--path` equivalent. The file is read at
425 * plan/reconcile. Content changes update in place.
426 *
427 * **Example:** Path
428 * ```typescript
429 * const ping = yield* Railway.Function("Ping", {
430 * project: site,
431 * path: "./fn.ts",
432 * });
433 * ```
434 *
435 * ### Cron
436 * `cronSchedule` runs the Function on a cron expression (`--cron`).
437 * HTTP domains are skipped unless `http: true` is set explicitly.
438 *
439 * **Example:** Cron schedule
440 * ```typescript
441 * const job = yield* Railway.Function("Cleanup", {
442 * project: site,
443 * source: `console.log("tick");`,
444 * cronSchedule: "0 * * * *",
445 * });
446 * ```
447 *
448 * ### Serverless
449 * `sleepApplication` sleeps the Function when idle (`--serverless`).
450 *
451 * **Example:** Sleep when idle
452 * ```typescript
453 * const ping = yield* Railway.Function("Ping", {
454 * project: site,
455 * source: `console.log("hi");`,
456 * http: false,
457 * sleepApplication: true,
458 * });
459 * ```
460 *
461 * ### Schemaless RPC
462 * Return methods next to `fetch`. Call `enableRailwayRpc()` in init
463 * (canvas Functions are capped at 96KB; the dispatcher is opt-in).
464 * Another Function or Service binds this class and calls them over
465 * `{name}.railway.internal` with a shared token. Public
466 * `*.up.railway.app` requests to `/__rpc__/*` get 401.
467 *
468 * **Example:** Bind a Function
469 * ```typescript
470 * export default class Query extends Railway.Function<Query>()(
471 * "Query",
472 * { project: Site, main: import.meta.url },
473 * Effect.gen(function* () {
474 * Railway.enableRailwayRpc();
475 * return {
476 * greet: (name: string) => Effect.succeed(`hello ${name}`),
477 * };
478 * }),
479 * ) {}
480 *
481 * export default class Api extends Railway.Service<Api>()(
482 * "Api",
483 * { project: Site, main: import.meta.url },
484 * Effect.gen(function* () {
485 * const query = yield* Railway.bindFunction(Query);
486 * return {
487 * fetch: query
488 * .greet("sam")
489 * .pipe(Effect.map((greeting) => HttpServerResponse.text(greeting))),
490 * };
491 * }),
492 * ) {}
493 * ```
494 *
495 * ### Module-scope declarations
496 * Resource-valued props accept the resource or an Effect producing it.
497 *
498 * **Example:** Module-scope Function
499 * ```typescript
500 * // src/ping.ts
501 * import * as Railway from "alchemy/Railway";
502 *
503 * export const Site = Railway.Project("Site");
504 * export const Ping = Railway.Function("Ping", {
505 * project: Site,
506 * source: `console.log("hello");`,
507 * http: false,
508 * });
509 * ```
510 *
511 * @resource
512 * @product Function
513 */
514export const Function: Platform<
515 Function,
516 FunctionServices,
517 FunctionShape,
518 FunctionRuntimeContext
519> & {
520 /**
521 * Async (no-impl) constructor — captures the `env` literal so
522 * {@link InferEnv} types the handler's second argument.
523 */
524 <const Env extends Record<string, any>, PropsReq = never>(
525 id: string,
526 props:
527 | InputProps<FunctionProps<Env>>
528 | Effect.Effect<InputProps<FunctionProps<Env>>, never, PropsReq>,
529 ): Effect.Effect<Function<Env>, never, Providers | PropsReq>;
530} = Platform("Railway.Function", {
531 createRuntimeContext: createRailwayFunctionRuntimeContext("Railway.Function"),
532 transformProps: (id, props) => resolveFunctionProps(id, props),
533});
534
535export class FunctionNotCreated extends Data.TaggedError(
536 "Railway.FunctionNotCreated",
537)<{
538 name: string;
539 projectId: string;
540}> {}
541
542export class FunctionProjectRequired extends Data.TaggedError(
543 "Railway.FunctionProjectRequired",
544)<{
545 message: string;
546}> {}
547
548export class FunctionSourceRequired extends Data.TaggedError(
549 "Railway.FunctionSourceRequired",
550)<{
551 message: string;
552}> {}
553
554export class FunctionTooLarge extends Data.TaggedError(
555 "Railway.FunctionTooLarge",
556)<{
557 bytes: number;
558 maxBytes: number;
559}> {
560 get message() {
561 return `canvas Function is ${this.bytes} bytes (max ${this.maxBytes})`;
562 }
563}
564
565export class FunctionRuntimeImageMissing extends Data.TaggedError(
566 "Railway.FunctionRuntimeImageMissing",
567)<{
568 message: string;
569}> {}
570
571export class FunctionDeployFailed extends Data.TaggedError(
572 "Railway.FunctionDeployFailed",
573)<{
574 serviceId: string;
575 status: string;
576 deploymentId: string | undefined;
577 logs: string;
578}> {
579 override get message() {
580 return this.logs.length > 0
581 ? `Function deploy ${this.status}: ${this.logs}`
582 : `Function deploy ${this.status}`;
583 }
584}
585
586class FunctionPending extends Data.TaggedError("Railway.FunctionPending")<{
587 serviceId: string;
588 status: string;
589}> {}
590
591class FunctionDeployPending extends Data.TaggedError(
592 "Railway.FunctionDeployPending",
593)<{
594 serviceId: string;
595 status: string;
596}> {}
597
598const projectIdOf = (value: unknown): string | undefined => {
599 if (value === null || typeof value !== "object") return undefined;
600 const rec = value as { projectId?: unknown };
601 return typeof rec.projectId === "string" && rec.projectId.length > 0
602 ? rec.projectId
603 : undefined;
604};
605
606const environmentIdOf = (value: unknown): string | undefined => {
607 if (value === null || typeof value !== "object") return undefined;
608 const rec = value as { environmentId?: unknown };
609 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
610 ? rec.environmentId
611 : undefined;
612};
613
614const isGoneService = (service: CloudService | undefined) =>
615 service === undefined || service.deletedAt != null;
616
617const isGoneInstance = (instance: ServiceInstanceResponse | undefined) =>
618 instance === undefined || instance.deletedAt != null;
619
620const resolveName = (id: string, name: string | undefined, existing?: string) =>
621 Effect.gen(function* () {
622 if (name !== undefined) return name;
623 if (existing !== undefined) return existing;
624 return yield* createRailwayName(id);
625 });
626
627const wantsHttp = (props: FunctionProps) =>
628 props.http === true ||
629 (props.http !== false && props.cronSchedule === undefined);
630
631const hashSource = (source: string) =>
632 Effect.sync(() => createHash("sha256").update(source).digest("hex"));
633
634const startCommandOf = (source: string) =>
635 Effect.gen(function* () {
636 const encoded = yield* Effect.sync(() =>
637 Buffer.from(source, "utf8").toString("base64"),
638 );
639 const cmd = `${FUNCTION_START_PREFIX}${encoded}`;
640 if (cmd.length > FUNCTION_MAX_BYTES) {
641 return yield* new FunctionTooLarge({
642 bytes: cmd.length,
643 maxBytes: FUNCTION_MAX_BYTES,
644 });
645 }
646 return cmd;
647 });
648
649const resolveSource = (props: FunctionProps) =>
650 Effect.gen(function* () {
651 if (props.source !== undefined && props.source.length > 0) {
652 return props.source;
653 }
654 if (props.path !== undefined && props.path.length > 0) {
655 const fs = yield* FileSystem.FileSystem;
656 return yield* fs.readFileString(props.path);
657 }
658 return yield* new FunctionSourceRequired({
659 message:
660 "Railway.Function requires `source` (inline TypeScript), `path` to a single file, or `main` (Effect-native).",
661 });
662 });
663
664const readRuntimeImage = Query.fn(() => ({
665 image: Railway.functionRuntime({ name: FUNCTION_RUNTIME_NAME }).latestVersion
666 .image,
667}));
668
669const latestRuntimeImage = () =>
670 readRuntimeImage().pipe(
671 Effect.flatMap(({ image }) => {
672 if (image.length === 0) {
673 return new FunctionRuntimeImageMissing({
674 message: "functionRuntime(bun) returned an empty latestVersion.image",
675 });
676 }
677 return Effect.succeed(image);
678 }),
679 );
680
681const sameImage = (observed: string | null | undefined, desired: string) => {
682 if (observed == null || observed.length === 0) return false;
683 if (observed === desired) return true;
684 if (observed === `${desired}:latest` || desired === `${observed}:latest`) {
685 return true;
686 }
687 return (
688 observed.endsWith(`/${desired}`) || observed.endsWith(`/${desired}:latest`)
689 );
690};
691
692const deployReady = (status: string | undefined) =>
693 status === "SUCCESS" || status === "SLEEPING";
694
695const deployFailed = (status: string | undefined) =>
696 status === "FAILED" || status === "CRASHED" || status === "REMOVED";
697
698const readService = Query.fn((id: string) =>
699 serviceFields(Railway.service({ id })),
700);
701
702const getById = (serviceId: string) =>
703 readService(serviceId).pipe(
704 Effect.map((service) => (isGoneService(service) ? undefined : service)),
705 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
706 );
707
708const readInstance = Query.fn((environmentId: string, serviceId: string) =>
709 instanceFields(Railway.serviceInstance({ environmentId, serviceId })),
710);
711
712const getInstance = (environmentId: string, serviceId: string) =>
713 readInstance(environmentId, serviceId).pipe(
714 Effect.map((instance) => (isGoneInstance(instance) ? undefined : instance)),
715 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
716 );
717
718const listProjectServices = (projectId: string) =>
719 fetchProjectServices(projectId, serviceFields).pipe(
720 Effect.map((services) => services.filter((node) => !isGoneService(node))),
721 Effect.catchTag("RailwayNotFound", () =>
722 Effect.succeed([] as CloudService[]),
723 ),
724 );
725
726const findByName = (projectId: string, name: string) =>
727 listProjectServices(projectId).pipe(
728 Effect.map((services) => services.find((service) => service.name === name)),
729 );
730
731const waitForInstance = (environmentId: string, serviceId: string) =>
732 getInstance(environmentId, serviceId).pipe(
733 Effect.flatMap((instance) => {
734 if (instance === undefined) {
735 return Effect.fail(
736 new FunctionPending({ serviceId, status: "creating" }),
737 );
738 }
739 return Effect.succeed(instance);
740 }),
741 Effect.retry({
742 while: (e) => e._tag === "Railway.FunctionPending",
743 times: 10,
744 schedule: Schedule.spaced("2 seconds"),
745 }),
746 Effect.catchTag("Railway.FunctionPending", () =>
747 getInstance(environmentId, serviceId),
748 ),
749 );
750
751const readDeployLogs = Query.fn((deploymentId: string) =>
752 Railway.deploymentLogs({ deploymentId, limit: 80 }).pipe(
753 Query.map((row) => ({ severity: row.severity, message: row.message })),
754 ),
755);
756
757const fetchDeployLogs = (deploymentId: string | undefined) =>
758 deploymentId === undefined || deploymentId.length === 0
759 ? Effect.succeed("")
760 : readDeployLogs(deploymentId).pipe(
761 Effect.map((rows) =>
762 rows
763 .map((row) =>
764 row.severity != null
765 ? `[${row.severity}] ${row.message}`
766 : row.message,
767 )
768 .join("\n"),
769 ),
770 Effect.orElseSucceed(() => ""),
771 );
772
773const waitForDeployment = (environmentId: string, serviceId: string) =>
774 Effect.gen(function* () {
775 const instance = yield* getInstance(environmentId, serviceId);
776 const latest = instance?.latestDeployment;
777 const status = latest?.status;
778 if (status !== undefined && deployFailed(status)) {
779 const logs = yield* fetchDeployLogs(latest?.id);
780 return yield* new FunctionDeployFailed({
781 serviceId,
782 status,
783 deploymentId: latest?.id,
784 logs,
785 });
786 }
787 if (instance !== undefined && deployReady(status)) {
788 return instance;
789 }
790 return yield* new FunctionDeployPending({
791 serviceId,
792 status: status ?? "pending",
793 });
794 }).pipe(
795 Effect.retry({
796 while: (e) => e._tag === "Railway.FunctionDeployPending",
797 // Queued builds under full-suite load can exceed 3 minutes — allow ~8.
798 times: 10,
799 schedule: Schedule.spaced("5 seconds"),
800 }),
801 );
802
803const asVariableMap = (value: unknown): Record<string, string> => {
804 if (value === null || typeof value !== "object" || Array.isArray(value)) {
805 return {};
806 }
807 const out: Record<string, string> = {};
808 for (const [key, item] of Object.entries(value)) {
809 if (typeof item === "string") {
810 out[key] = item;
811 }
812 }
813 return out;
814};
815
816const readVariables = Query.fn(
817 (projectId: string, environmentId: string, serviceId: string) =>
818 Railway.variables({
819 projectId,
820 environmentId,
821 serviceId,
822 unrendered: true,
823 }),
824);
825
826const variableUpsert = Query.fn((input: VariableUpsertInput) =>
827 Railway.variableUpsert({ input }),
828);
829
830const listVariableMap = (
831 projectId: string,
832 environmentId: string,
833 serviceId: string,
834) =>
835 readVariables(projectId, environmentId, serviceId).pipe(
836 Effect.map(asVariableMap),
837 Effect.catchTag("RailwayNotFound", () =>
838 Effect.succeed({} as Record<string, string>),
839 ),
840 );
841
842const upsertVariable = (input: {
843 projectId: string;
844 environmentId: string;
845 serviceId: string;
846 name: string;
847 value: string;
848}) =>
849 variableUpsert({
850 projectId: input.projectId,
851 environmentId: input.environmentId,
852 serviceId: input.serviceId,
853 name: input.name,
854 value: input.value,
855 skipDeploys: true,
856 });
857
858const serviceCreate = Query.fn((input: ServiceCreateInput) =>
859 serviceFields(Railway.serviceCreate({ input })),
860);
861
862const serviceUpdate = Query.fn((id: string, name: string) =>
863 serviceFields(Railway.serviceUpdate({ id, input: { name } })),
864);
865
866const serviceInstanceUpdate = Query.fn(
867 (
868 environmentId: string,
869 serviceId: string,
870 input: ServiceInstanceUpdateInput,
871 ) => Railway.serviceInstanceUpdate({ environmentId, serviceId, input }),
872);
873
874const serviceInstanceDeploy = Query.fn(
875 (environmentId: string, serviceId: string) =>
876 Railway.serviceInstanceDeployV2({ environmentId, serviceId }),
877);
878
879const serviceDelete = Query.fn((id: string) => Railway.serviceDelete({ id }));
880
881const hostedProgramProps = (
882 props: FunctionProps,
883 port: number | undefined,
884): HostedProgramProps | undefined => {
885 if (props.main === undefined || props.main.length === 0) return undefined;
886 return {
887 main: props.main,
888 handler: props.handler,
889 port,
890 env: props.env,
891 isExternal: props.isExternal,
892 build: props.build,
893 };
894};
895
896const syncEnv = Effect.fn(function* (input: {
897 projectId: string;
898 environmentId: string;
899 serviceId: string;
900 desired: Record<string, string>;
901}) {
902 if (Object.keys(input.desired).length === 0) return false;
903 const observed = yield* listVariableMap(
904 input.projectId,
905 input.environmentId,
906 input.serviceId,
907 );
908 let changed = false;
909 for (const [name, value] of Object.entries(input.desired)) {
910 if (observed[name] !== value) {
911 yield* upsertVariable({
912 projectId: input.projectId,
913 environmentId: input.environmentId,
914 serviceId: input.serviceId,
915 name,
916 value,
917 });
918 changed = true;
919 }
920 }
921 return changed;
922});
923
924const syncMounts = Effect.fn(function* (input: {
925 environmentId: string;
926 projectId: string;
927 serviceId: string;
928 mounts: MountSpec[];
929}) {
930 for (const mount of input.mounts) {
931 yield* attachVolumeToService({
932 environmentId: input.environmentId,
933 projectId: input.projectId,
934 serviceId: input.serviceId,
935 volumeId: mount.volumeId,
936 mountPath: mount.path,
937 });
938 }
939});
940
941const toAttrs = (input: {
942 service: CloudService;
943 instance: AttributeInstance | undefined;
944 domain: ServiceDomainRecord | undefined;
945 projectId: string;
946 environmentId: string;
947 image: string;
948 port: number | undefined;
949 codeHash: string;
950 rpcToken: string;
951 region: string | undefined;
952}): Function["Attributes"] => ({
953 serviceId: input.service.id,
954 name: input.service.name,
955 projectId: input.projectId,
956 environmentId: input.environmentId,
957 image: input.instance?.source?.image ?? input.image,
958 runtime: FUNCTION_RUNTIME_NAME,
959 cronSchedule: input.instance?.cronSchedule ?? undefined,
960 sleepApplication: input.instance?.sleepApplication ?? undefined,
961 region: input.region,
962 port: input.port ?? input.domain?.targetPort,
963 dnsName: `${input.service.name}.railway.internal`,
964 rpcToken: input.rpcToken,
965 url: input.domain?.url,
966 domain: input.domain?.domain,
967 domainId: input.domain?.id,
968 deploymentId: input.instance?.latestDeployment?.id,
969 deploymentStatus: input.instance?.latestDeployment?.status,
970 nextCronRunAt: input.instance?.nextCronRunAt ?? undefined,
971 code: { hash: input.codeHash },
972});
973
974const instanceSettingsDelta = (input: {
975 instance: ServiceInstanceResponse | undefined;
976 sourceImage: string;
977 startCommand: string;
978 props: FunctionProps;
979}): ServiceInstanceUpdateInput | undefined => {
980 const instance = input.instance;
981 const delta: Types.Mutable<ServiceInstanceUpdateInput> = {};
982 let changed = false;
983
984 if (!sameImage(instance?.source?.image, input.sourceImage)) {
985 delta.source = { image: input.sourceImage };
986 changed = true;
987 }
988 if ((instance?.startCommand ?? undefined) !== input.startCommand) {
989 delta.startCommand = input.startCommand;
990 changed = true;
991 }
992 if (
993 input.props.cronSchedule !== undefined &&
994 (instance?.cronSchedule ?? undefined) !== input.props.cronSchedule
995 ) {
996 delta.cronSchedule = input.props.cronSchedule;
997 changed = true;
998 }
999 if (
1000 input.props.sleepApplication !== undefined &&
1001 instance?.sleepApplication !== input.props.sleepApplication
1002 ) {
1003 delta.sleepApplication = input.props.sleepApplication;
1004 changed = true;
1005 }
1006 return changed ? delta : undefined;
1007};
1008
1009export const FunctionProvider = () =>
1010 Provider.effect(
1011 Function,
1012 Effect.gen(function* () {
1013 const stack = yield* Stack;
1014 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
1015 const hosted = createRailwayFunctionSupport({
1016 stackName: stack.name,
1017 stage: stack.stage,
1018 virtualEntryPlugin,
1019 });
1020
1021 return Function.Provider.of({
1022 stables: ["serviceId", "projectId", "environmentId"],
1023 nuke: { dependsOn: ["Railway.Project"] },
1024
1025 diff: Effect.fn(function* ({ news: desired, output }) {
1026 // Runtime exports are covered by the bundle hash, not evaluated
1027 // as infrastructure inputs during planning.
1028 const news = stripEffects(desired);
1029 if (news === undefined || !isResolved(news)) return undefined;
1030 if (output === undefined) return undefined;
1031 const nextProject = projectIdOf(news.project);
1032 const projectChanged =
1033 nextProject !== undefined && nextProject !== output.projectId;
1034 const nextEnv = environmentIdOf(news.environment);
1035 const environmentChanged =
1036 nextEnv !== undefined && nextEnv !== output.environmentId;
1037 if (projectChanged || environmentChanged) {
1038 return { action: "replace" as const };
1039 }
1040 const program = hostedProgramProps(news, news.port);
1041 if (program !== undefined) {
1042 const hash = yield* hosted.hash(program);
1043 if (hash !== output.code.hash) {
1044 return { action: "update" as const };
1045 }
1046 return undefined;
1047 }
1048 const hasSource =
1049 (news.source !== undefined && news.source.length > 0) ||
1050 (news.path !== undefined && news.path.length > 0);
1051 if (hasSource) {
1052 const source = yield* resolveSource(news);
1053 const hash = yield* hashSource(source);
1054 if (hash !== output.code.hash) {
1055 return { action: "update" as const };
1056 }
1057 }
1058 return undefined;
1059 }),
1060
1061 read: Effect.fn(function* ({ id, olds, output }) {
1062 const projectId =
1063 output?.projectId ??
1064 (olds !== undefined ? projectIdOf(olds.project) : undefined);
1065 const environmentId =
1066 output?.environmentId ??
1067 (olds !== undefined
1068 ? (environmentIdOf(olds.environment) ??
1069 environmentIdOf(olds.project))
1070 : undefined);
1071 const name = yield* resolveName(id, olds?.name, output?.name);
1072 const byId =
1073 output?.serviceId !== undefined && output.serviceId.length > 0
1074 ? yield* getById(output.serviceId)
1075 : undefined;
1076 const found =
1077 byId ??
1078 (projectId !== undefined
1079 ? yield* findByName(projectId, name)
1080 : undefined);
1081 if (found === undefined) return undefined;
1082 const resolvedProjectId = projectIdOf(found) ?? projectId ?? "";
1083 const resolvedEnvId =
1084 environmentId ??
1085 environmentIdOf(olds?.project) ??
1086 output?.environmentId ??
1087 "";
1088 const instance =
1089 resolvedEnvId.length > 0
1090 ? yield* getInstance(resolvedEnvId, found.id)
1091 : undefined;
1092 const region =
1093 resolvedEnvId.length > 0
1094 ? yield* readServiceRegion({
1095 environmentId: resolvedEnvId,
1096 projectId: resolvedProjectId,
1097 serviceId: found.id,
1098 legacy: instance?.region,
1099 })
1100 : undefined;
1101 const attrs = toAttrs({
1102 service: found,
1103 instance,
1104 domain: undefined,
1105 projectId: resolvedProjectId,
1106 environmentId: resolvedEnvId,
1107 image: instance?.source?.image ?? output?.image ?? "",
1108 port: output?.port ?? olds?.port,
1109 codeHash: output?.code.hash ?? "",
1110 rpcToken: output?.rpcToken ?? "",
1111 region,
1112 });
1113 if (output !== undefined) return attrs;
1114 return matchesAlchemyPhysicalName(found.name)
1115 ? attrs
1116 : Unowned(attrs);
1117 }),
1118
1119 list: Effect.fn(function* () {
1120 const projects = yield* ownedProjects();
1121 const rows = yield* Effect.forEach(projects, (project) =>
1122 Effect.gen(function* () {
1123 const services = new Map(
1124 (yield* listProjectServices(project.projectId))
1125 .filter((service) => matchesAlchemyPhysicalName(service.name))
1126 .map((service) => [service.id, service]),
1127 );
1128 if (services.size === 0) return [];
1129 const envIds = yield* projectEnvironmentIds(project);
1130 const items = yield* Effect.forEach(envIds, (environmentId) =>
1131 environmentServiceInstances(
1132 environmentId,
1133 project.projectId,
1134 (instance) => ({
1135 ...attributeInstanceFields(instance),
1136 serviceId: instance.serviceId,
1137 deletedAt: instance.deletedAt,
1138 }),
1139 ).pipe(
1140 Effect.map((instances) =>
1141 instances.flatMap((instance) => {
1142 const service = services.get(instance.serviceId);
1143 const image = instance.source?.image;
1144 if (
1145 instance.deletedAt != null ||
1146 service === undefined ||
1147 !isFunctionImage(image)
1148 )
1149 return [];
1150 return [
1151 toAttrs({
1152 service,
1153 instance,
1154 domain: undefined,
1155 projectId: project.projectId,
1156 environmentId,
1157 image: image ?? "",
1158 port: undefined,
1159 codeHash: "",
1160 rpcToken: "",
1161 region: instance.region ?? undefined,
1162 }),
1163 ];
1164 }),
1165 ),
1166 ),
1167 );
1168 return items.flat();
1169 }),
1170 );
1171 return rows.flat();
1172 }),
1173
1174 // Circular Service↔Function RPC binds `dnsName` / `port` / `rpcToken`.
1175 // Those are knowable from the physical name and props; the cloud
1176 // service is created in reconcile (and re-synced in converge).
1177 precreate: Effect.fn(function* ({ id, news }) {
1178 const name = yield* resolveName(
1179 id,
1180 typeof news.name === "string" ? news.name : undefined,
1181 );
1182 const port = typeof news.port === "number" ? news.port : DEFAULT_PORT;
1183 return {
1184 serviceId: "",
1185 name,
1186 projectId: "",
1187 environmentId: "",
1188 image: "",
1189 runtime: FUNCTION_RUNTIME_NAME,
1190 cronSchedule: undefined,
1191 sleepApplication: undefined,
1192 region: undefined,
1193 port,
1194 dnsName: `${name}.railway.internal`,
1195 rpcToken: plainEnvValue(news.rpcToken) ?? "",
1196 url: undefined,
1197 domain: undefined,
1198 domainId: undefined,
1199 deploymentId: undefined,
1200 deploymentStatus: undefined,
1201 nextCronRunAt: undefined,
1202 code: { hash: "" },
1203 } satisfies Function["Attributes"];
1204 }),
1205
1206 reconcile: Effect.fn(function* ({ id, news, output, bindings }) {
1207 const props = news ?? ({} as FunctionProps);
1208 const projectId = projectIdOf(props.project) ?? output?.projectId;
1209 if (projectId === undefined) {
1210 return yield* new FunctionProjectRequired({
1211 message: "Function requires a resolved Railway.Project",
1212 });
1213 }
1214 const environmentId =
1215 environmentIdOf(props.environment) ??
1216 environmentIdOf(props.project) ??
1217 output?.environmentId;
1218 if (environmentId === undefined) {
1219 return yield* new FunctionProjectRequired({
1220 message:
1221 "Function requires a Railway environment (pass environment or a Project with environmentId)",
1222 });
1223 }
1224 const name = yield* resolveName(id, props.name, output?.name);
1225 const bound = collectBindingState(bindings ?? []);
1226 yield* assertHostDisk({
1227 name,
1228 mounts: bound.mounts,
1229 });
1230 const program = hostedProgramProps(
1231 props,
1232 props.port ?? (props.main !== undefined ? DEFAULT_PORT : undefined),
1233 );
1234 let source: string;
1235 let codeHash: string;
1236 if (program !== undefined) {
1237 const bundled = yield* hosted.bundleToSource(program);
1238 source = bundled.source;
1239 codeHash = bundled.hash;
1240 } else {
1241 source = yield* resolveSource(props);
1242 codeHash = yield* hashSource(source);
1243 }
1244 const startCommand = yield* startCommandOf(source);
1245 const image = yield* latestRuntimeImage();
1246 // Effect-native Functions listen on PORT (canvas default 3000).
1247 // Pin PORT and the generated domain's targetPort together so
1248 // private-mesh RPC and public HTTP hit the same listener.
1249 const port =
1250 program !== undefined ? (props.port ?? DEFAULT_PORT) : props.port;
1251 const rpcToken =
1252 plainEnvValue(props.rpcToken) ?? output?.rpcToken ?? "";
1253 const env = {
1254 ...bound.env,
1255 ...(program !== undefined ? hosted.alchemyEnv : {}),
1256 ...(port !== undefined ? { PORT: String(port) } : {}),
1257 ...toEnvRecord(props.env),
1258 [RPC_TOKEN_ENV]: rpcToken,
1259 };
1260
1261 let current: CloudService | undefined =
1262 output?.serviceId !== undefined && output.serviceId.length > 0
1263 ? yield* getById(output.serviceId)
1264 : undefined;
1265 if (current === undefined) {
1266 current = yield* findByName(projectId, name);
1267 }
1268
1269 if (current === undefined) {
1270 const created = yield* serviceCreate({
1271 projectId,
1272 environmentId,
1273 name,
1274 source: { image },
1275 }).pipe(
1276 Effect.catchTag("RailwayValidationError", () =>
1277 Effect.succeed(undefined),
1278 ),
1279 );
1280 current = created ?? (yield* findByName(projectId, name));
1281 }
1282
1283 if (current === undefined || isGoneService(current)) {
1284 return yield* new FunctionNotCreated({ name, projectId });
1285 }
1286
1287 if (current.name !== name) {
1288 current = yield* serviceUpdate(current.id, name);
1289 }
1290
1291 // The service instance must exist in this environment before a
1292 // domain can be generated. Extra non-fork environments lag.
1293 let instance = yield* waitForInstance(environmentId, current.id);
1294
1295 // Domain before PORT — Railway refuses serviceDomainCreate on a
1296 // service that already has PORT set ("please try again").
1297 let domain = wantsHttp(props)
1298 ? yield* ensureServiceDomain({
1299 projectId,
1300 environmentId,
1301 serviceId: current.id,
1302 })
1303 : undefined;
1304 let needsDeploy = false;
1305
1306 const instanceDelta = instanceSettingsDelta({
1307 instance,
1308 sourceImage: image,
1309 startCommand,
1310 props,
1311 });
1312 if (instanceDelta !== undefined) {
1313 yield* serviceInstanceUpdate(
1314 environmentId,
1315 current.id,
1316 instanceDelta,
1317 );
1318 needsDeploy = true;
1319 instance =
1320 (yield* getInstance(environmentId, current.id)) ?? instance;
1321 }
1322
1323 // Pin `deploy.multiRegionConfig` before the build, then again after,
1324 // in case the deploy wrote the workspace default back.
1325 const serviceId = current.id;
1326 const placeRegion = () =>
1327 syncServiceRegion({
1328 environmentId,
1329 projectId,
1330 serviceId,
1331 region: props.region,
1332 legacy: instance?.region,
1333 fallbackReplicas: instance?.numReplicas,
1334 });
1335 let region = yield* placeRegion();
1336
1337 const envChanged = yield* syncEnv({
1338 projectId,
1339 environmentId,
1340 serviceId: current.id,
1341 desired: env,
1342 });
1343 if (envChanged) needsDeploy = true;
1344
1345 if (wantsHttp(props) && port !== undefined) {
1346 domain = yield* ensureServiceDomain({
1347 projectId,
1348 environmentId,
1349 serviceId: current.id,
1350 targetPort: port,
1351 });
1352 }
1353
1354 yield* syncMounts({
1355 environmentId,
1356 projectId,
1357 serviceId: current.id,
1358 mounts: bound.mounts,
1359 });
1360
1361 if (needsDeploy || instance?.latestDeployment == null) {
1362 yield* serviceInstanceDeploy(environmentId, current.id).pipe(
1363 Effect.catchTag("RailwayValidationError", () => Effect.void),
1364 );
1365 }
1366
1367 // Cron-only Functions sleep until the schedule. Waiting for
1368 // SUCCESS queues behind sibling Docker builds and can sit in
1369 // QUEUED/BUILDING for the full retry budget. HTTP Functions
1370 // still wait — they have a public URL to serve.
1371 if (wantsHttp(props)) {
1372 instance =
1373 (yield* waitForDeployment(environmentId, current.id)) ?? instance;
1374 } else {
1375 instance =
1376 (yield* getInstance(environmentId, current.id)) ?? instance;
1377 const status = instance?.latestDeployment?.status;
1378 if (status !== undefined && deployFailed(status)) {
1379 const logs = yield* fetchDeployLogs(
1380 instance?.latestDeployment?.id,
1381 );
1382 return yield* new FunctionDeployFailed({
1383 serviceId: current.id,
1384 status,
1385 deploymentId: instance?.latestDeployment?.id,
1386 logs,
1387 });
1388 }
1389 }
1390
1391 region = yield* placeRegion();
1392
1393 return toAttrs({
1394 service: current,
1395 instance,
1396 domain,
1397 projectId,
1398 environmentId,
1399 image,
1400 port,
1401 codeHash,
1402 rpcToken,
1403 region,
1404 });
1405 }),
1406
1407 delete: Effect.fn(function* ({ output }) {
1408 const serviceId = output.serviceId;
1409 if (serviceId.length === 0) return;
1410 yield* serviceDelete(serviceId).pipe(
1411 Effect.catchTag("RailwayNotFound", () => Effect.void),
1412 );
1413 yield* waitUntilDeleted(
1414 "Service",
1415 serviceId,
1416 getById(serviceId).pipe(
1417 Effect.map((service) => service === undefined),
1418 ),
1419 );
1420 }),
1421 });
1422 }),
1423 );