Files
notification-service/Sources/NotificationService/HTTPServer.swift
T
2026-07-06 01:17:30 +08:00

232 lines
8.4 KiB
Swift

import Foundation
import Darwin
struct HTTPRequest {
let method: String
let path: String
let headers: [String: String]
let body: Data
}
struct HTTPResponse {
var status: Int = 200
var headers: [String: String] = ["Content-Type": "application/json"]
var body: String = ""
init(status: Int = 200, body: String = "") {
self.status = status
self.body = body
}
}
enum HTTPServerError: Error, CustomStringConvertible {
case socketFailed
case bindFailed(port: UInt16)
case listenFailed
var description: String {
switch self {
case .socketFailed: return "socket() failed"
case .bindFailed(let port): return "bind() failed on 127.0.0.1:\(port) (already running?)"
case .listenFailed: return "listen() failed"
}
}
}
/// Minimal single-threaded-per-connection HTTP/1.1 server bound to a specific
/// local address. Uses BSD sockets + GCD so there are no third-party deps and
/// the bind address can be locked to the loopback interface.
final class HTTPServer {
private let host: String
private let port: UInt16
private let handler: (HTTPRequest) -> HTTPResponse
private var listenFd: Int32 = -1
private var listenSource: DispatchSourceRead?
private let queue = DispatchQueue(label: "notification-service.http.listener")
init(host: String, port: UInt16, handler: @escaping (HTTPRequest) -> HTTPResponse) {
self.host = host
self.port = port
self.handler = handler
}
func start() throws {
// Writing to a socket whose peer has closed should not kill us.
signal(SIGPIPE, SIG_IGN)
listenFd = socket(AF_INET, SOCK_STREAM, 0)
if listenFd < 0 { throw HTTPServerError.socketFailed }
var reuse: Int32 = 1
setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size(ofValue: reuse)))
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = port.bigEndian // htons
addr.sin_addr.s_addr = inet_addr(host) // 127.0.0.1 -> INADDR_LOOPBACK (network order)
let bindResult = withUnsafePointer(to: &addr) { ptr -> Int32 in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
bind(listenFd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
if bindResult < 0 { throw HTTPServerError.bindFailed(port: port) }
if listen(listenFd, 16) < 0 { throw HTTPServerError.listenFailed }
// Non-blocking listener driven by a GCD read source.
let flags = fcntl(listenFd, F_GETFL, 0)
_ = fcntl(listenFd, F_SETFL, flags | O_NONBLOCK)
let fd = listenFd
let source = DispatchSource.makeReadSource(fileDescriptor: listenFd, queue: queue)
source.setEventHandler { [weak self] in self?.acceptConnections() }
source.setCancelHandler { close(fd) }
source.resume()
listenSource = source
}
func stop() {
listenSource?.cancel()
listenSource = nil
}
// MARK: - Accept loop
private func acceptConnections() {
while true {
let clientFd = accept(listenFd, nil, nil)
if clientFd < 0 {
// EWOULDBLOCK/EAGAIN means we've drained the backlog.
if errno == EWOULDBLOCK || errno == EAGAIN { return }
return
}
let ownFd = clientFd
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.handleConnection(fd: ownFd)
}
}
}
private func handleConnection(fd: Int32) {
defer { close(fd) }
guard let request = readRequest(fd: fd) else {
sendResponse(fd: fd, response: HTTPResponse(status: 400, body: "{\"error\":\"bad request\"}"))
return
}
let response = handler(request)
sendResponse(fd: fd, response: response)
}
// MARK: - Request parsing
private func readRequest(fd: Int32) -> HTTPRequest? {
var buffer = Data()
var tmp = [UInt8](repeating: 0, count: 4096)
let separator = Data([0x0D, 0x0A, 0x0D, 0x0A]) // \r\n\r\n
while true {
let n = tmp.withUnsafeMutableBufferPointer { buf -> Int in
guard let base = buf.baseAddress else { return -1 }
return recv(fd, UnsafeMutableRawPointer(base), Int(buf.count), 0)
}
if n < 0 {
if errno == EINTR { continue }
return nil
}
if n == 0 { break } // peer closed
buffer.append(contentsOf: tmp[0..<n])
if let sepRange = buffer.range(of: separator) {
let headerEnd = sepRange.lowerBound
let bodyStart = sepRange.upperBound
let headerStr = String(data: buffer.subdata(in: 0..<headerEnd), encoding: .utf8) ?? ""
let headers = Self.parseHeaders(headerStr)
let contentLength = Int(headers["content-length"] ?? "0") ?? 0
// Keep reading until the full body is in the buffer.
while buffer.count - bodyStart < contentLength {
let m = tmp.withUnsafeMutableBufferPointer { buf -> Int in
guard let base = buf.baseAddress else { return -1 }
return recv(fd, UnsafeMutableRawPointer(base), Int(buf.count), 0)
}
if m < 0 {
if errno == EINTR { continue }
return nil
}
if m == 0 { break }
buffer.append(contentsOf: tmp[0..<m])
}
let bodyEnd = min(bodyStart + contentLength, buffer.count)
let body = buffer.subdata(in: bodyStart..<bodyEnd)
return Self.parseRequest(header: headerStr, body: body)
}
}
return nil
}
private static func parseRequest(header: String, body: Data) -> HTTPRequest? {
let lines = header.components(separatedBy: "\r\n")
guard let firstLine = lines.first else { return nil }
let parts = firstLine.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true)
guard parts.count >= 2 else { return nil }
let method = String(parts[0])
let path = String(parts[1])
let headers = parseHeaders(header)
return HTTPRequest(method: method, path: path, headers: headers, body: body)
}
private static func parseHeaders(_ header: String) -> [String: String] {
var headers: [String: String] = [:]
let lines = header.components(separatedBy: "\r\n")
for line in lines.dropFirst() {
guard let colonIdx = line.firstIndex(of: ":") else { continue }
let key = String(line[..<colonIdx]).trimmingCharacters(in: .whitespaces).lowercased()
let val = String(line[line.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
headers[key] = val
}
return headers
}
// MARK: - Response
private func sendResponse(fd: Int32, response: HTTPResponse) {
let bodyData = response.body.data(using: .utf8) ?? Data()
var header = "HTTP/1.1 \(response.status) \(Self.statusText(response.status))\r\n"
for (k, v) in response.headers { header += "\(k): \(v)\r\n" }
header += "Content-Length: \(bodyData.count)\r\n"
header += "Connection: close\r\n"
header += "\r\n"
var payload = header.data(using: .utf8) ?? Data()
payload.append(bodyData)
payload.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in
guard let base = ptr.baseAddress else { return }
var sent = 0
while sent < payload.count {
let n = send(fd, base.advanced(by: sent), payload.count - sent, 0)
if n <= 0 {
if n < 0 && errno == EINTR { continue }
break
}
sent += n
}
}
}
private static func statusText(_ status: Int) -> String {
switch status {
case 200: return "OK"
case 201: return "Created"
case 400: return "Bad Request"
case 404: return "Not Found"
case 405: return "Method Not Allowed"
case 500: return "Internal Server Error"
default: return "Status"
}
}
}