| 1 | import FoundationEssentials |
| 2 | import Hummingbird |
| 3 | import Logging |
| 4 | |
| 5 | /// How the binary was started: `--socket <path> --db <path>` on Carloku, |
| 6 | /// or `--listen 127.0.0.1:<port> --db <path>` for local runs and tests. |
| 7 | public struct Options: Sendable, Equatable { |
| 8 | public enum Address: Sendable, Equatable { |
| 9 | case socket(String) |
| 10 | case listen(host: String, port: Int) |
| 11 | } |
| 12 | |
| 13 | public var address: Address |
| 14 | public var database: String |
| 15 | /// `CARLOS_HOST`, the instance's hostname, if the platform set it. |
| 16 | public var carlosHost: String? |
| 17 | |
| 18 | public struct UsageError: Error, CustomStringConvertible { |
| 19 | public var description: String |
| 20 | } |
| 21 | |
| 22 | public static let usage = "usage: todo (--socket <path> | --listen 127.0.0.1:<port>) --db <path>" |
| 23 | |
| 24 | public init(arguments: [String], environment: [String: String]) throws { |
| 25 | var socket: String? |
| 26 | var listen: String? |
| 27 | var database: String? |
| 28 | var rest = arguments[...] |
| 29 | while let flag = rest.popFirst() { |
| 30 | guard let value = rest.popFirst() else { throw UsageError(description: "\(flag) needs a value\n\(Self.usage)") } |
| 31 | switch flag { |
| 32 | case "--socket", "-socket": socket = value |
| 33 | case "--listen", "-listen": listen = value |
| 34 | case "--db", "-db": database = value |
| 35 | default: throw UsageError(description: "unknown flag \(flag)\n\(Self.usage)") |
| 36 | } |
| 37 | } |
| 38 | guard let database else { throw UsageError(description: Self.usage) } |
| 39 | self.database = database |
| 40 | switch (socket, listen) { |
| 41 | case (let path?, nil): |
| 42 | address = .socket(path) |
| 43 | case (nil, let listen?): |
| 44 | guard let colon = listen.lastIndex(of: ":"), listen[..<colon] == "127.0.0.1", |
| 45 | let port = Int(listen[listen.index(after: colon)...]), (1...65535).contains(port) |
| 46 | else { throw UsageError(description: "--listen takes 127.0.0.1:<port>; nothing else is bound") } |
| 47 | address = .listen(host: "127.0.0.1", port: port) |
| 48 | default: |
| 49 | throw UsageError(description: Self.usage) |
| 50 | } |
| 51 | carlosHost = environment["CARLOS_HOST"].flatMap { $0.isEmpty ? nil : $0 } |
| 52 | } |
| 53 | |
| 54 | /// The origin a browser sends with the app's own form posts. |
| 55 | var origin: String? { |
| 56 | switch address { |
| 57 | case .listen(let host, let port): "http://\(host):\(port)" |
| 58 | case .socket: carlosHost.map { "https://\($0)" } |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /// On a socket the platform's edge serves the app over https. |
| 63 | var servedOverHTTPS: Bool { |
| 64 | if case .socket = address { return true } |
| 65 | return false |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | public enum Server { |
| 70 | /// Builds the router for a store; the tests drive this directly. |
| 71 | public static func router(store: Store, options: Options, version: String = buildVersion) -> Router<BasicRequestContext> { |
| 72 | let router = Router() |
| 73 | router.add(middleware: OriginCheck(origin: options.origin)) |
| 74 | Routes(store: store, visitors: Visitors(store: store, secure: options.servedOverHTTPS), version: version) |
| 75 | .register(on: router) |
| 76 | return router |
| 77 | } |
| 78 | |
| 79 | /// Serves until SIGTERM or SIGINT, then closes the database and returns. |
| 80 | public static func run(_ options: Options) async throws { |
| 81 | var logger = Logger(label: "todo-swift") |
| 82 | logger.logLevel = .info |
| 83 | let store = try await Store.open(path: options.database) |
| 84 | |
| 85 | let address: BindAddress |
| 86 | switch options.address { |
| 87 | case .socket(let path): |
| 88 | // A socket file left behind by an earlier process would make bind fail. |
| 89 | try? FileManager.default.removeItem(atPath: path) |
| 90 | address = .unixDomainSocket(path: path) |
| 91 | case .listen(let host, let port): |
| 92 | address = .hostname(host, port: port) |
| 93 | } |
| 94 | |
| 95 | let app = Application( |
| 96 | router: router(store: store, options: options), |
| 97 | configuration: .init(address: address), |
| 98 | logger: logger |
| 99 | ) |
| 100 | do { |
| 101 | try await app.runService() |
| 102 | } catch { |
| 103 | try? await store.close() |
| 104 | throw error |
| 105 | } |
| 106 | try await store.close() |
| 107 | if case .socket(let path) = options.address { |
| 108 | try? FileManager.default.removeItem(atPath: path) |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | |