1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as HttpClient from "effect/http/HttpClient";
5import type * as HttpClientError from "effect/http/HttpClientError";
6import * as HttpClientRequest from "effect/http/HttpClientRequest";
7import type * as HttpClientResponse from "effect/http/HttpClientResponse";
8import * as Binding from "../Binding.ts";
9import type { Resource } from "../Resource.ts";
10import { makeFetchRpcStub, RPC_PATH_PREFIX, type Rpc } from "../Rpc.ts";
11import {
12 CurrentRuntimeContext,
13 type RuntimeContext,
14 sanitizeKey,
15} from "../RuntimeContext.ts";
16import { isYieldableEffectLike } from "../Util/effect.ts";
17import type { BoundTarget, ServiceBinding } from "./MountVolume.ts";
18import { RPC_TOKEN_HEADER } from "./rpc.ts";
19import type { Service } from "./Service.ts";
20
21/** The deployed caller has no address for the bound Service. */
22export class BoundServiceUnavailable extends Data.TaggedError(
23 "Fly.BoundServiceUnavailable",
24)<{
25 service: string;
26}> {
27 get message() {
28 return `No private address for Fly Service ${this.service}. It publishes no ports, or the caller was deployed before the binding existed.`;
29 }
30}
31
32/**
33 * Client for a Service bound with {@link bindService}: its RPC methods,
34 * plus `fetch` for its HTTP routes.
35 */
36export type ServiceClient<Shape> = Omit<Shape, "fetch" | "run"> & {
37 /**
38 * Send a request to the Service's `fetch` handler over Fly's private
39 * network. A relative URL such as `/users/u1` resolves against the
40 * Service's private address.
41 */
42 fetch(
43 request: HttpClientRequest.HttpClientRequest,
44 ): Effect.Effect<
45 HttpClientResponse.HttpClientResponse,
46 HttpClientError.HttpClientError | BoundServiceUnavailable,
47 RuntimeContext
48 >;
49};
50
51/** A published port of a Service bound with {@link bindEndpoint}. */
52export interface EndpointClient {
53 /** `{appName}.flycast`, the bound Service's private hostname. */
54 host: Effect.Effect<string, BoundServiceUnavailable, RuntimeContext>;
55 /** The published port. */
56 port: number;
57 /** `http://{appName}.flycast:{port}`, for a port that serves plain HTTP. */
58 url: Effect.Effect<string, BoundServiceUnavailable, RuntimeContext>;
59 /** An `HttpClient` whose relative requests go to {@link url}. */
60 client: HttpClient.HttpClient.With<
61 HttpClientError.HttpClientError | BoundServiceUnavailable
62 >;
63}
64
65type Target<Shape> = Service & Rpc<Shape>;
66
67const isFlyHost = (
68 value: unknown,
69): value is Resource<string, any, any, ServiceBinding> =>
70 typeof value === "object" &&
71 value !== null &&
72 ((value as { Type?: string }).Type === "Fly.Service" ||
73 (value as { Type?: string }).Type === "Fly.Machine");
74
75/** Logical id without yielding the Service (the runtime has no engine). */
76const logicalIdOf = (target: unknown): string => {
77 const id =
78 target !== null &&
79 (typeof target === "object" || typeof target === "function")
80 ? (target as { LogicalId?: unknown }).LogicalId
81 : undefined;
82 return typeof id === "string" ? id : "";
83};
84
85/** Machine env vars the caller's reconcile writes for a bound Service. */
86export const boundTargetEnvKeys = (id: string) => ({
87 appName: sanitizeKey(`FLY_BIND_${id}_APP`),
88 url: sanitizeKey(`FLY_BIND_${id}_URL`),
89 token: sanitizeKey(`FLY_BIND_${id}_TOKEN`),
90});
91
92/**
93 * Record the binding on the caller and transport the target's private
94 * address and caller token as Outputs. Returns runtime accessors for them.
95 */
96const bindTarget = Effect.fn(function* <Shape, Req>(
97 target: Target<Shape> | Effect.Effect<Target<Shape>, never, Req>,
98 port: number | undefined,
99) {
100 const id = logicalIdOf(target);
101 const keys = boundTargetEnvKeys(id);
102 const ctx = yield* CurrentRuntimeContext;
103 if (!globalThis.__ALCHEMY_RUNTIME__) {
104 // Yielding registers the target and makes the caller depend on it.
105 const service = isYieldableEffectLike(target)
106 ? yield* target as Effect.Effect<Target<Shape>, never, Req>
107 : target;
108 // The binding carries the target's Outputs. Unlike props, binding
109 // data may stay unresolved between cycle peers, so two Services can
110 // bind each other; the caller's reconcile writes them onto its Machines.
111 const host = yield* Binding.Host;
112 if (isFlyHost(host)) {
113 yield* host.bind`${service}`({
114 target: {
115 service: id,
116 appName: service.appName,
117 network: service.network,
118 privateUrl: service.privateUrl,
119 rpcToken: service.rpcToken,
120 port,
121 endpoints: service.endpoints,
122 } as unknown as BoundTarget,
123 });
124 }
125 }
126 // Captured at bind time: the host's runtime context reads the values
127 // transported onto the Machine.
128 const read = (key: string) =>
129 ctx === undefined ? Effect.succeed(undefined) : ctx.get<unknown>(key);
130 const required = (key: string) =>
131 read(key).pipe(
132 Effect.flatMap((value) =>
133 typeof value === "string" && value.length > 0
134 ? Effect.succeed(value)
135 : Effect.fail(new BoundServiceUnavailable({ service: id })),
136 ),
137 );
138 return {
139 appName: required(keys.appName),
140 url: required(keys.url),
141 token: read(keys.token).pipe(
142 Effect.map((value) =>
143 Redacted.isRedacted(value)
144 ? String(Redacted.value(value))
145 : typeof value === "string"
146 ? value
147 : "",
148 ),
149 ),
150 };
151});
152
153/** Resolve `path` (relative or placeholder-based) against `base`. */
154const withBase = (base: string, url: string) => {
155 const path = url.replace(/^https?:\/\/[^/]+/, "");
156 return `${base.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
157};
158
159/**
160 * Bind another {@link Service} and get a typed client for it. The client
161 * calls the Service's methods (everything its program returns besides
162 * `fetch` and `run`) and forwards `fetch` requests, over Fly's private
163 * network.
164 *
165 * Binding makes the caller deploy after the Service and hands it the
166 * Service's private address and caller token. Only callers that bind a
167 * Service receive its token, so other Services on the same network cannot
168 * call its methods. Every call must also carry Fly's signed `Fly-Src`
169 * header from the same organization, so the public internet cannot call
170 * them. Calls are plain HTTP inside Fly's WireGuard-encrypted network.
171 *
172 * ### Call a private Service
173 * **Example:** Methods on the target
174 * ```typescript
175 * export default class Users extends Fly.Service<Users>()(
176 * "Users",
177 * { main: import.meta.url, public: false },
178 * Effect.gen(function* () {
179 * return {
180 * list: () => Effect.succeed(USERS),
181 * get: (id: string) =>
182 * Effect.succeed(USERS.find((user) => user.id === id)),
183 * };
184 * }),
185 * ) {}
186 * ```
187 *
188 * **Example:** Bind it and call a method
189 * ```typescript
190 * export default class Orders extends Fly.Service<Orders>()(
191 * "Orders",
192 * { main: import.meta.url, public: false },
193 * Effect.gen(function* () {
194 * const users = yield* Fly.bindService(Users);
195 * return {
196 * list: () =>
197 * Effect.forEach(ORDERS, (order) =>
198 * users
199 * .get(order.userId)
200 * .pipe(Effect.map((user) => ({ ...order, user }))),
201 * ),
202 * };
203 * }),
204 * ) {}
205 * ```
206 *
207 * ### Stream and forward HTTP
208 * **Example:** A method that returns a Stream
209 * ```typescript
210 * const all = yield* users.streamAll().pipe(Stream.runCollect);
211 * ```
212 *
213 * **Example:** Send a request to the Service's `fetch`
214 * ```typescript
215 * const response = yield* users.fetch(HttpClientRequest.get("/users/u1"));
216 * ```
217 *
218 * ### Bind one port
219 * {@link bindEndpoint} targets one published port, such as an admin API
220 * or a raw TCP protocol. Deploy fails with `Fly.EndpointNotPublished`
221 * when the Service does not publish it.
222 *
223 * **Example:** An admin API on port 9000
224 * ```typescript
225 * const admin = yield* Fly.bindEndpoint(Users, { port: 9000 });
226 * const stats = yield* admin.client.get("/stats");
227 * const host = yield* admin.host; // "{appName}.flycast"
228 * ```
229 *
230 * ### Two Services that bind each other
231 * Declare each as a tag class with its method shape, and implement each
232 * in its own file as the default export of `.make`.
233 *
234 * **Example:** Ping and Pong
235 * ```typescript
236 * // src/services.ts
237 * export class Ping extends Fly.Service<Ping, Named>()("Ping") {}
238 * export class Pong extends Fly.Service<Pong, Named>()("Pong") {}
239 *
240 * // src/ping.ts
241 * export default Ping.make(
242 * { main: import.meta.url },
243 * Effect.gen(function* () {
244 * const pong = yield* Fly.bindService(Pong);
245 * return { name: () => Effect.succeed("ping") };
246 * }),
247 * );
248 * ```
249 *
250 * ### Errors
251 * Each fails the caller's deploy before the caller is created:
252 * `Fly.ServiceUnreachable` when the caller and the Service are on
253 * different networks, `Fly.ServiceNotBindable` when the Service publishes
254 * no ports, and `Fly.EndpointNotPublished` for a port it does not publish.
255 *
256 * @binding
257 * @product Service
258 */
259export const bindService = <Shape, Req = never>(
260 target: Target<Shape> | Effect.Effect<Target<Shape>, never, Req>,
261): Effect.Effect<ServiceClient<Shape>, never, Req | HttpClient.HttpClient> =>
262 Effect.gen(function* () {
263 const http = yield* HttpClient.HttpClient;
264 const bound = yield* bindTarget(target, undefined);
265 const send = (
266 request: HttpClientRequest.HttpClientRequest,
267 isRpc: boolean,
268 ) =>
269 Effect.gen(function* () {
270 const base = yield* bound.url;
271 let next = HttpClientRequest.setUrl(
272 request,
273 withBase(base, request.url),
274 );
275 if (isRpc) {
276 next = HttpClientRequest.setHeader(
277 next,
278 RPC_TOKEN_HEADER,
279 yield* bound.token,
280 );
281 }
282 return yield* http.execute(next);
283 });
284 return makeFetchRpcStub<ServiceClient<Shape>>({
285 fetch: (request) =>
286 send(request, request.url.includes(RPC_PATH_PREFIX)) as Effect.Effect<
287 HttpClientResponse.HttpClientResponse,
288 unknown
289 >,
290 base: {
291 fetch: (request: HttpClientRequest.HttpClientRequest) =>
292 send(request, false),
293 },
294 });
295 });
296
297/**
298 * Bind one published port of another {@link Service}: an admin API on
299 * its own port, or a raw TCP protocol. Returns the port's private host,
300 * its URL when it serves plain HTTP, and an `HttpClient` whose relative
301 * requests go there. See {@link bindService}.
302 */
303export const bindEndpoint = <Shape, Req = never>(
304 target: Target<Shape> | Effect.Effect<Target<Shape>, never, Req>,
305 options: { port: number },
306): Effect.Effect<EndpointClient, never, Req | HttpClient.HttpClient> =>
307 Effect.gen(function* () {
308 const http = yield* HttpClient.HttpClient;
309 const bound = yield* bindTarget(target, options.port);
310 const host = bound.appName.pipe(Effect.map((app) => `${app}.flycast`));
311 const url = host.pipe(
312 Effect.map((name) =>
313 options.port === 80
314 ? `http://${name}`
315 : `http://${name}:${options.port}`,
316 ),
317 );
318 return {
319 host,
320 port: options.port,
321 url,
322 client: HttpClient.mapRequestEffect(http, (request) =>
323 url.pipe(
324 Effect.map((base) =>
325 HttpClientRequest.setUrl(request, withBase(base, request.url)),
326 ),
327 ),
328 ),
329 } satisfies EndpointClient;
330 });