1import { safeHttpEffect } from "@/Http";
2import { bindFunction } from "@/Railway/Bind.ts";
3import { serveRailwayRpc } from "@/Railway/rpc-server.ts";
4import {
5 RPC_PATH_PREFIX,
6 RPC_TOKEN_ENV,
7 RPC_TOKEN_HEADER,
8} from "@/Railway/rpc-token.ts";
9import { describe, expect, it } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as HttpEffect from "effect/http/HttpEffect";
12import * as HttpServerResponse from "effect/http/HttpServerResponse";
13
14const TOKEN = "a".repeat(64);
15const PRIVATE_URL = "http://greeter.railway.internal:3000";
16const PUBLIC_URL = "https://greeter.up.railway.app";
17
18const shape = {
19 greet: (name: string) => Effect.succeed(`hello ${name}`),
20};
21
22const fallback = Effect.succeed(HttpServerResponse.text("ok"));
23
24const webHandler = HttpEffect.toWebHandler(
25 safeHttpEffect(serveRailwayRpc(shape, fallback)),
26);
27
28const fetchImpl = ((url: any, init?: any) => {
29 const href = typeof url === "string" ? url : String(url);
30 const parsed = new URL(href);
31 const headers = new Headers(init?.headers);
32 headers.set("host", parsed.host);
33 return webHandler(new Request(href, { ...init, headers }));
34}) as typeof globalThis.fetch;
35
36const withEnv = <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E> =>
37 Effect.suspend(() => {
38 const previous = process.env[RPC_TOKEN_ENV];
39 process.env[RPC_TOKEN_ENV] = TOKEN;
40 return effect.pipe(
41 Effect.ensuring(
42 Effect.sync(() => {
43 if (previous === undefined) {
44 delete process.env[RPC_TOKEN_ENV];
45 } else {
46 process.env[RPC_TOKEN_ENV] = previous;
47 }
48 }),
49 ),
50 );
51 });
52
53const rpcPost = (url: string, headers: Record<string, string> = {}) =>
54 webHandler(
55 new Request(url, {
56 method: "POST",
57 headers: {
58 "content-type": "application/json",
59 ...headers,
60 },
61 body: JSON.stringify(["sam"]),
62 }),
63 );
64
65describe.sequential(
66 "Railway private-mesh RPC",
67 { tags: ["unit", "provider:railway", "local"] },
68 () => {
69 it("serves value methods on the private mesh with the token", () =>
70 withEnv(
71 Effect.gen(function* () {
72 const response = yield* Effect.promise(() =>
73 rpcPost(`${PRIVATE_URL}${RPC_PATH_PREFIX}greet`, {
74 host: "greeter.railway.internal:3000",
75 [RPC_TOKEN_HEADER]: TOKEN,
76 }),
77 );
78 expect(response.status).toBe(200);
79 expect(yield* Effect.promise(() => response.text())).toBe(
80 JSON.stringify("hello sam"),
81 );
82 }),
83 ));
84
85 it("401s public Host even with the token", () =>
86 withEnv(
87 Effect.gen(function* () {
88 const response = yield* Effect.promise(() =>
89 rpcPost(`${PUBLIC_URL}${RPC_PATH_PREFIX}greet`, {
90 host: "greeter.up.railway.app",
91 [RPC_TOKEN_HEADER]: TOKEN,
92 }),
93 );
94 expect(response.status).toBe(401);
95 }),
96 ));
97
98 it("401s the private mesh without a token", () =>
99 withEnv(
100 Effect.gen(function* () {
101 const response = yield* Effect.promise(() =>
102 rpcPost(`${PRIVATE_URL}${RPC_PATH_PREFIX}greet`, {
103 host: "greeter.railway.internal:3000",
104 }),
105 );
106 expect(response.status).toBe(401);
107 }),
108 ));
109
110 it("401s a wrong token", () =>
111 withEnv(
112 Effect.gen(function* () {
113 const response = yield* Effect.promise(() =>
114 rpcPost(`${PRIVATE_URL}${RPC_PATH_PREFIX}greet`, {
115 host: "greeter.railway.internal:3000",
116 [RPC_TOKEN_HEADER]: "b".repeat(64),
117 }),
118 );
119 expect(response.status).toBe(401);
120 }),
121 ));
122
123 it("401s when X-Forwarded-Host is a public domain", () =>
124 withEnv(
125 Effect.gen(function* () {
126 const response = yield* Effect.promise(() =>
127 rpcPost(`${PRIVATE_URL}${RPC_PATH_PREFIX}greet`, {
128 host: "greeter.railway.internal:3000",
129 "x-forwarded-host": "greeter.up.railway.app",
130 [RPC_TOKEN_HEADER]: TOKEN,
131 }),
132 );
133 expect(response.status).toBe(401);
134 }),
135 ));
136
137 it("falls through to fetch outside /__rpc__/", () =>
138 withEnv(
139 Effect.gen(function* () {
140 const response = yield* Effect.promise(() =>
141 webHandler(new Request(`${PUBLIC_URL}/`)),
142 );
143 expect(response.status).toBe(200);
144 expect(yield* Effect.promise(() => response.text())).toBe("ok");
145 }),
146 ));
147
148 it(
149 "bindFunction stub round-trips through the private mesh",
150 () =>
151 withEnv(
152 Effect.sync(() => {
153 const previousFetch = globalThis.fetch;
154 globalThis.__ALCHEMY_RUNTIME__ = true;
155 globalThis.fetch = fetchImpl;
156 process.env.RAILWAY_RPC_GREETER_HOST = "greeter.railway.internal";
157 process.env.RAILWAY_RPC_GREETER_PORT = "3000";
158 process.env.RAILWAY_RPC_GREETER_TOKEN = TOKEN;
159 return previousFetch;
160 }).pipe(
161 Effect.flatMap((previousFetch) =>
162 Effect.gen(function* () {
163 const stub = yield* bindFunction<{
164 greet: (name: string) => Effect.Effect<string>;
165 }>({
166 Type: "Railway.Function",
167 LogicalId: "Greeter",
168 } as never);
169 const greeting = yield* stub.greet("sam");
170 expect(greeting).toBe("hello sam");
171 }).pipe(
172 Effect.ensuring(
173 Effect.sync(() => {
174 globalThis.fetch = previousFetch;
175 globalThis.__ALCHEMY_RUNTIME__ = false;
176 delete process.env.RAILWAY_RPC_GREETER_HOST;
177 delete process.env.RAILWAY_RPC_GREETER_PORT;
178 delete process.env.RAILWAY_RPC_GREETER_TOKEN;
179 }),
180 ),
181 ),
182 ),
183 ),
184 ),
185 { tags: ["provider:railway:bind"] },
186 );
187 },
188);