1import type { FlyMachineService } from "@distilled.cloud/fly-io/machines";
2import * as Data from "effect/Data";
3import type { MachineService } from "./Machine.ts";
4
5/** One port, or an inclusive port range, published on Fly's proxy. */
6export interface PublishedPort {
7 protocol: string;
8 start: number;
9 end: number;
10 handlers: string[];
11 forceHttps: boolean;
12}
13
14export class ServicePortConflict extends Data.TaggedError(
15 "Fly.ServicePortConflict",
16)<{
17 appName: string;
18 port: number;
19 protocol: string;
20 /** The two publishers of the port: Service logical ids or Machine ids. */
21 publishers: [string, string];
22}> {
23 get message() {
24 return `Port ${this.port}/${this.protocol} in Fly App ${this.appName} is published by both ${this.publishers[0]} and ${this.publishers[1]}. Fly's proxy routes an App's traffic by port only, so give each Service in the App its own ports (\`services\`, or \`bindingPort\` for the plain-HTTP port Alchemy adds for bound callers).`;
25 }
26}
27
28const normalize = (entry: {
29 protocol?: string;
30 port?: number;
31 startPort?: number;
32 endPort?: number;
33 handlers?: string[];
34 forceHttps?: boolean;
35}): PublishedPort[] => {
36 const start = entry.port ?? entry.startPort;
37 const end = entry.port ?? entry.endPort ?? entry.startPort;
38 if (start === undefined || end === undefined) return [];
39 return [
40 {
41 protocol: entry.protocol ?? "tcp",
42 start,
43 end,
44 handlers: entry.handlers ?? [],
45 forceHttps: entry.forceHttps === true,
46 },
47 ];
48};
49
50/** Published ports of observed or desired Fly Machine services. */
51export const portsOfFly = (
52 services: readonly FlyMachineService[] | null | undefined,
53): PublishedPort[] =>
54 (services ?? []).flatMap((service) =>
55 (service.ports ?? []).flatMap((port) =>
56 normalize({
57 protocol: service.protocol,
58 port: port.port,
59 startPort: port.start_port,
60 endPort: port.end_port,
61 handlers: port.handlers,
62 forceHttps: port.force_https,
63 }),
64 ),
65 );
66
67/**
68 * Published ports of a Service's `services` prop, falling back to the
69 * defaults: HTTP 80 (redirecting to HTTPS) and HTTPS 443 when public,
70 * plain HTTP 80 when private.
71 */
72export const portsOfProps = (
73 services: readonly MachineService[] | undefined,
74 isPublic: boolean,
75): PublishedPort[] =>
76 services !== undefined
77 ? services.flatMap((service) =>
78 (service.ports ?? []).flatMap((port) =>
79 normalize({ protocol: service.protocol, ...port }),
80 ),
81 )
82 : isPublic
83 ? [
84 {
85 protocol: "tcp",
86 start: 80,
87 end: 80,
88 handlers: ["http"],
89 forceHttps: true,
90 },
91 {
92 protocol: "tcp",
93 start: 443,
94 end: 443,
95 handlers: ["tls", "http"],
96 forceHttps: false,
97 },
98 ]
99 : [
100 {
101 protocol: "tcp",
102 start: 80,
103 end: 80,
104 handlers: ["http"],
105 forceHttps: false,
106 },
107 ];
108
109const isPlainHttp = (port: PublishedPort) =>
110 port.start === port.end &&
111 port.protocol === "tcp" &&
112 port.handlers.includes("http") &&
113 !port.handlers.includes("tls") &&
114 !port.forceHttps;
115
116/**
117 * The port bound callers use: the first plain-HTTP port, since Fly issues
118 * no certificate for `.flycast`. When the Service publishes ports but none
119 * of them is plain HTTP, Alchemy publishes `bindingPort` for bindings.
120 */
121export const bindingPortOf = (
122 ports: readonly PublishedPort[],
123 bindingPort: number,
124): { port: number; added: boolean } | undefined => {
125 if (ports.length === 0) return undefined;
126 const others = ports.filter(
127 (port) => !(isPlainHttp(port) && port.start === bindingPort),
128 );
129 const plain = others.find(isPlainHttp);
130 if (plain !== undefined) return { port: plain.start, added: false };
131 return { port: bindingPort, added: true };
132};
133
134/** `ports` plus the binding port Alchemy adds, when it adds one. */
135export const withBindingPort = (
136 ports: readonly PublishedPort[],
137 bindingPort: number,
138): PublishedPort[] => {
139 const binding = bindingPortOf(ports, bindingPort);
140 return binding?.added === true &&
141 !ports.some((port) => isPlainHttp(port) && port.start === bindingPort)
142 ? [
143 ...ports,
144 {
145 protocol: "tcp",
146 start: bindingPort,
147 end: bindingPort,
148 handlers: ["http"],
149 forceHttps: false,
150 },
151 ]
152 : [...ports];
153};
154
155const overlaps = (left: PublishedPort, right: PublishedPort) =>
156 left.protocol === right.protocol &&
157 left.start <= right.end &&
158 right.start <= left.end;
159
160/**
161 * The first port two publishers share, if any. A publisher that lists the
162 * same port twice also conflicts with itself.
163 */
164export const findPortConflict = (
165 appName: string,
166 publishers: ReadonlyArray<{ id: string; ports: readonly PublishedPort[] }>,
167): ServicePortConflict | undefined => {
168 const flat = publishers.flatMap((publisher) =>
169 publisher.ports.map((port) => ({ id: publisher.id, port })),
170 );
171 for (let i = 0; i < flat.length; i++) {
172 for (let j = i + 1; j < flat.length; j++) {
173 const left = flat[i]!;
174 const right = flat[j]!;
175 if (!overlaps(left.port, right.port)) continue;
176 return new ServicePortConflict({
177 appName,
178 port: Math.max(left.port.start, right.port.start),
179 protocol: left.port.protocol,
180 publishers: [left.id, right.id],
181 });
182 }
183 }
184 return undefined;
185};