| 1 | import type { FlyMachineService } from "@distilled.cloud/fly-io/machines"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import type { MachineService } from "./Machine.ts"; |
| 4 | |
| 5 | /** One port, or an inclusive port range, published on Fly's proxy. */ |
| 6 | export interface PublishedPort { |
| 7 | protocol: string; |
| 8 | start: number; |
| 9 | end: number; |
| 10 | handlers: string[]; |
| 11 | forceHttps: boolean; |
| 12 | } |
| 13 | |
| 14 | export 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 | |
| 28 | const 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. */ |
| 51 | export 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 | */ |
| 72 | export 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 | |
| 109 | const 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 | */ |
| 121 | export 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. */ |
| 135 | export 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 | |
| 155 | const 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 | */ |
| 164 | export 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 | }; |