1import * as Effect from "effect/Effect";
2import * as Result from "effect/Result";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5import type { HttpEffect } from "../Http.ts";
6import {
7 PRIVATE_HOST_SUFFIX,
8 RPC_ERROR_TAG,
9 RPC_PATH_PREFIX,
10 RPC_TOKEN_ENV,
11 RPC_TOKEN_HEADER,
12} from "./rpc-token.ts";
13
14const header = (
15 headers: Record<string, string | undefined>,
16 name: string,
17): string => headers[name] ?? headers[name.toLowerCase()] ?? "";
18
19const hostnameOf = (host: string): string => {
20 const trimmed = host.trim();
21 if (trimmed.startsWith("[")) {
22 const end = trimmed.indexOf("]");
23 return end === -1 ? trimmed : trimmed.slice(1, end);
24 }
25 const colon = trimmed.lastIndexOf(":");
26 if (colon > 0 && trimmed.includes(".")) return trimmed.slice(0, colon);
27 return trimmed;
28};
29
30const isPrivateMesh = (host: string): boolean =>
31 hostnameOf(host).endsWith(PRIVATE_HOST_SUFFIX);
32
33const tokensEqual = (left: string, right: string): boolean => {
34 if (left.length !== right.length) return false;
35 let mismatch = 0;
36 for (let i = 0; i < left.length; i++) {
37 mismatch |= left.charCodeAt(i) ^ right.charCodeAt(i);
38 }
39 return mismatch === 0;
40};
41
42const unauthorized = HttpServerResponse.text("Unauthorized", { status: 401 });
43
44/**
45 * Serve schemaless RPC only on the private mesh, and only with the
46 * host's {@link RPC_TOKEN_ENV}. Public `*.up.railway.app` requests to
47 * `/__rpc__/*` get 401 even if they guess the path or the token.
48 *
49 * Implemented here (not `alchemy/Rpc.serveRpc`) so canvas Functions stay
50 * under the 96KB start-command cap. Value methods only — no streams.
51 *
52 * Importing this module registers the Function-runtime hook. Tagged
53 * Functions that host RPC methods must import it (or `bindFunction`,
54 * which re-exports this module).
55 */
56export const serveRailwayRpc = <Req = never>(
57 shape: Record<string, unknown>,
58 fallback: HttpEffect<Req>,
59): HttpEffect<Req> =>
60 Effect.gen(function* () {
61 const request = yield* HttpServerRequest;
62 const prefixAt = request.url.indexOf(RPC_PATH_PREFIX);
63 if (prefixAt === -1) {
64 return yield* fallback;
65 }
66
67 const headers = request.headers as Record<string, string | undefined>;
68 const hostHeader = header(headers, "host");
69 const host =
70 hostHeader.length > 0
71 ? hostHeader
72 : yield* Effect.sync(() => {
73 try {
74 return new URL(request.url).host;
75 } catch {
76 return "";
77 }
78 });
79 const forwarded = header(headers, "x-forwarded-host");
80 const expected = yield* Effect.sync(() => process.env[RPC_TOKEN_ENV] ?? "");
81 const provided = header(headers, RPC_TOKEN_HEADER);
82 if (
83 !isPrivateMesh(host) ||
84 (forwarded.length > 0 && !isPrivateMesh(forwarded)) ||
85 expected.length === 0 ||
86 provided.length === 0 ||
87 !tokensEqual(provided, expected)
88 ) {
89 return unauthorized;
90 }
91
92 let name = request.url.slice(prefixAt + RPC_PATH_PREFIX.length);
93 const queryAt = name.indexOf("?");
94 if (queryAt !== -1) name = name.slice(0, queryAt);
95 name = decodeURIComponent(name);
96
97 const method = shape[name];
98 if (typeof method !== "function") {
99 return HttpServerResponse.text(`Unknown RPC method "${name}"`, {
100 status: 404,
101 });
102 }
103
104 const text = yield* request.text;
105 let args: unknown[] = [];
106 if (text.length > 0) {
107 try {
108 args = JSON.parse(text) as unknown[];
109 } catch {
110 return HttpServerResponse.text(`Invalid RPC arguments for "${name}"`, {
111 status: 400,
112 });
113 }
114 }
115
116 const invoked = (method as (...a: unknown[]) => unknown)(...args);
117 if (!Effect.isEffect(invoked)) {
118 return yield* HttpServerResponse.json(invoked ?? null);
119 }
120 const result = yield* Effect.result(
121 invoked as Effect.Effect<unknown, unknown>,
122 );
123 if (Result.isSuccess(result)) {
124 return yield* HttpServerResponse.json(result.success ?? null);
125 }
126 return yield* HttpServerResponse.json({
127 _tag: RPC_ERROR_TAG,
128 error: result.failure,
129 });
130 });
131
132/** Register private-mesh RPC on this Function/Service isolate. */
133export const enableRailwayRpc = (): void => {
134 (globalThis as any).__R = serveRailwayRpc;
135};