1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Binding from "../Binding.ts";
4import type { Rpc } from "../Rpc.ts";
5import { unpackEnvValue } from "../RuntimeContext.ts";
6import { isYieldableEffectLike } from "../Util/effect.ts";
7import { isRailwayHost } from "./MountVolume.ts";
8import {
9 DEFAULT_RPC_PORT,
10 RPC_ERROR_TAG,
11 RPC_PATH_PREFIX,
12 RPC_TOKEN_HEADER,
13 rpcEnvKeys,
14} from "./rpc-token.ts";
15
16export class RpcCallError extends Data.TaggedError("Railway.RpcCallError")<{
17 readonly method: string;
18 readonly cause: unknown;
19}> {}
20
21export class RpcUnauthorized extends Data.TaggedError(
22 "Railway.RpcUnauthorized",
23)<{
24 readonly method: string;
25}> {}
26
27const fromProcessEnv = (key: string): string => {
28 const unpacked = unpackEnvValue<unknown>(process.env[key]);
29 if (typeof unpacked === "string") return unpacked;
30 return "";
31};
32
33type RpcTarget = {
34 readonly Type: string;
35 readonly LogicalId: string;
36 readonly dnsName?: unknown;
37 readonly port?: unknown;
38 readonly rpcToken?: unknown;
39};
40
41const resolveTarget = <Shape, Req>(
42 targetEff:
43 | (RpcTarget & Rpc<Shape>)
44 | Effect.Effect<RpcTarget & Rpc<Shape>, never, Req>,
45): Effect.Effect<RpcTarget & Rpc<Shape>, never, Req> =>
46 isYieldableEffectLike(targetEff)
47 ? (targetEff as Effect.Effect<RpcTarget & Rpc<Shape>, never, Req>)
48 : Effect.succeed(targetEff as RpcTarget & Rpc<Shape>);
49
50/** Logical id without yielding the Resource (runtime has no engine). */
51const logicalIdOf = (target: unknown): string => {
52 if (target !== null && typeof target === "object" && "LogicalId" in target) {
53 const id = (target as { LogicalId?: unknown }).LogicalId;
54 if (typeof id === "string" && id.length > 0) return id;
55 }
56 if (typeof target === "function") {
57 const named = (target as { name?: unknown }).name;
58 if (typeof named === "string" && named.length > 0) return named;
59 }
60 return "";
61};
62
63const makeStub = <Shape>(options: {
64 readonly baseUrl: string;
65 readonly token: string;
66}): Shape => {
67 const { baseUrl, token } = options;
68 return new Proxy(
69 {},
70 {
71 get: (_obj, prop) => {
72 if (typeof prop !== "string") return undefined;
73 return (...args: unknown[]) =>
74 Effect.gen(function* () {
75 if (!baseUrl || !token) {
76 return yield* new RpcCallError({
77 method: prop,
78 cause: !baseUrl ? "missing host" : "missing token",
79 });
80 }
81 const response = yield* Effect.tryPromise({
82 try: () =>
83 fetch(
84 `${baseUrl}${RPC_PATH_PREFIX}${encodeURIComponent(prop)}`,
85 {
86 method: "POST",
87 headers: {
88 "content-type": "application/json",
89 [RPC_TOKEN_HEADER]: token,
90 },
91 body: JSON.stringify(args),
92 signal: AbortSignal.timeout(25_000),
93 },
94 ),
95 catch: (cause) => new RpcCallError({ method: prop, cause }),
96 });
97 if (response.status === 401) {
98 return yield* new RpcUnauthorized({ method: prop });
99 }
100 const value = yield* Effect.tryPromise({
101 try: () => response.json() as Promise<unknown>,
102 catch: (cause) => new RpcCallError({ method: prop, cause }),
103 });
104 if (
105 typeof value === "object" &&
106 value !== null &&
107 (value as { _tag?: unknown })._tag === RPC_ERROR_TAG &&
108 "error" in value
109 ) {
110 return yield* Effect.fail((value as { error: unknown }).error);
111 }
112 return value;
113 });
114 },
115 },
116 ) as Shape;
117};
118
119const bindRpc = <Shape, Req = never>(
120 targetEff:
121 | (RpcTarget & Rpc<Shape>)
122 | Effect.Effect<RpcTarget & Rpc<Shape>, never, Req>,
123): Effect.Effect<Shape, never, Req> =>
124 Effect.gen(function* () {
125 if (!globalThis.__ALCHEMY_RUNTIME__) {
126 const target = yield* resolveTarget(targetEff);
127 const keys = rpcEnvKeys(target.LogicalId);
128 const host = yield* Binding.Host;
129 if (isRailwayHost(host)) {
130 yield* host.bind`${target}`({
131 env: {
132 [keys.host]: target.dnsName,
133 [keys.port]: target.port ?? DEFAULT_RPC_PORT,
134 [keys.token]: target.rpcToken,
135 },
136 });
137 }
138 }
139
140 const keys = rpcEnvKeys(logicalIdOf(targetEff));
141 const hostName = fromProcessEnv(keys.host);
142 const port = fromProcessEnv(keys.port);
143 const token = fromProcessEnv(keys.token);
144 const baseUrl =
145 hostName.length > 0
146 ? `http://${hostName}:${port.length > 0 ? port : String(DEFAULT_RPC_PORT)}`
147 : "";
148
149 return makeStub<Shape>({ baseUrl, token });
150 });
151
152/**
153 * Bind a {@link Function} and return a typed schemaless RPC stub.
154 * Calls go to `{dnsName}:{port}/__rpc__/{method}` on the private mesh
155 * with a shared token — not the public `*.up.railway.app` URL.
156 *
157 * @example
158 * ```typescript
159 * const query = yield* Railway.bindFunction(Query);
160 * const greeting = yield* query.greet("sam");
161 * ```
162 */
163export const bindFunction = bindRpc;
164
165/**
166 * Bind a {@link Service} and return a typed schemaless RPC stub.
167 * Same private-mesh + token path as {@link bindFunction}.
168 *
169 * @example
170 * ```typescript
171 * const api = yield* Railway.bindService(Api);
172 * const greeting = yield* api.greet("sam");
173 * ```
174 */
175export const bindService = bindRpc;