You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Musubi/Packages/MusubiKit/Sources/Intercom/HTTPServer.swift

156 lines
6.4 KiB

import Foundation
import Network
import OSLog
struct HTTPRequest: Equatable {
var method: String
var path: String
var headers: [String: String]
var body: [UInt8]
}
struct HTTPResponse {
var status: Int
var contentType: String
var body: [UInt8]
static func text(_ status: Int, _ text: String) -> HTTPResponse {
HTTPResponse(status: status, contentType: "text/plain; charset=utf-8", body: Array(text.utf8))
}
static func json(_ value: some Encodable) -> HTTPResponse {
HTTPResponse(status: 200, contentType: "application/json", body: Array((try? JSONEncoder().encode(value)) ?? Data()))
}
var serialized: [UInt8] {
let reason = [200: "OK", 204: "No Content", 400: "Bad Request", 404: "Not Found", 405: "Method Not Allowed",
413: "Payload Too Large", 503: "Service Unavailable"][status] ?? "Error"
let head = "HTTP/1.1 \(status) \(reason)\r\nContent-Type: \(contentType)\r\nContent-Length: \(body.count)\r\n"
+ "Cache-Control: no-store\r\nConnection: close\r\n\r\n"
return Array(head.utf8) + body
}
}
/// Incremental HTTP/1.1 request parser (no chunked bodies; the phone page only sends small JSON).
struct HTTPRequestParser {
enum ParseError: Error { case malformed, tooLarge }
static let maxHeader = 8 * 1024
static let maxBody = 64 * 1024
private var buffer: [UInt8] = []
/// Appends bytes; returns the request once it is complete.
mutating func append(_ bytes: [UInt8]) throws -> HTTPRequest? {
buffer += bytes
guard let headerEnd = Self.find([13, 10, 13, 10], in: buffer) else {
if buffer.count > Self.maxHeader { throw ParseError.tooLarge }
return nil
}
guard let head = String(bytes: buffer[0..<headerEnd], encoding: .utf8) else { throw ParseError.malformed }
let lines = head.components(separatedBy: "\r\n")
let requestLine = lines[0].split(separator: " ")
guard requestLine.count == 3, requestLine[2].hasPrefix("HTTP/1.") else { throw ParseError.malformed }
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":") else { continue }
headers[line[..<colon].lowercased()] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
}
let length = Int(headers["content-length"] ?? "0") ?? -1
guard length >= 0 else { throw ParseError.malformed }
guard length <= Self.maxBody else { throw ParseError.tooLarge }
let bodyStart = headerEnd + 4
guard buffer.count >= bodyStart + length else { return nil }
let path = String(requestLine[1].split(separator: "?", maxSplits: 1).first ?? "/")
return HTTPRequest(method: String(requestLine[0]), path: path, headers: headers,
body: Array(buffer[bodyStart..<bodyStart + length]))
}
private static func find(_ needle: [UInt8], in haystack: [UInt8]) -> Int? {
guard haystack.count >= needle.count else { return nil }
for i in 0...(haystack.count - needle.count) where haystack[i] == needle[0] {
if Array(haystack[i..<i + needle.count]) == needle { return i }
}
return nil
}
}
/// Minimal HTTP server bound to one interface. One request per connection.
final class HTTPServer: @unchecked Sendable {
typealias Handler = @Sendable (HTTPRequest) -> HTTPResponse
private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.http")
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
private var listener: NWListener?
private let handler: Handler
init(handler: @escaping Handler) {
self.handler = handler
}
func start(interface: NWInterface, port: UInt16) async throws {
let parameters = NWParameters.tcp
parameters.requiredInterface = interface
parameters.allowLocalEndpointReuse = true
parameters.includePeerToPeer = false
let listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port) ?? .any)
let once = OnceFlag()
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
listener.stateUpdateHandler = { [log] state in
switch state {
case .ready:
if once.claim() { continuation.resume() }
case .failed(let error):
log.error("HTTP failed: \(error.localizedDescription, privacy: .public)")
if once.claim() { continuation.resume(throwing: error) }
default: break
}
}
listener.newConnectionHandler = { [weak self] connection in self?.serve(connection) }
listener.start(queue: queue)
}
self.listener = listener
}
func stop() {
listener?.cancel()
listener = nil
}
private func serve(_ connection: NWConnection) {
connection.start(queue: queue)
read(connection, parser: ParserBox())
}
private func read(_ connection: NWConnection, parser: ParserBox) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, isComplete, error in
guard let self else { return }
do {
if let data, let request = try parser.value.append(Array(data)) {
// Signalling may block briefly (session setup); keep the HTTP queue free.
DispatchQueue.global(qos: .userInitiated).async {
self.respond(self.handler(request), on: connection)
}
return
}
} catch HTTPRequestParser.ParseError.tooLarge {
respond(.text(413, "Zu groß."), on: connection)
return
} catch {
respond(.text(400, "Ungültige Anfrage."), on: connection)
return
}
if isComplete || error != nil { connection.cancel() } else { read(connection, parser: parser) }
}
}
private func respond(_ response: HTTPResponse, on connection: NWConnection) {
connection.send(content: Data(response.serialized), completion: .contentProcessed { _ in connection.cancel() })
}
}
/// Per-connection parser state; only touched on the HTTP queue.
private final class ParserBox: @unchecked Sendable {
var value = HTTPRequestParser()
}

Powered by TurnKey Linux.