Created
March 29, 2024 16:28
-
-
Save stephancasas/f7d2d6cf19077a539539cc06bce77be4 to your computer and use it in GitHub Desktop.
A low-level HTTP server offering a SwiftUI-observable lifecycle.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// DumbHTTPServer.swift | |
// DumbHTTPServer | |
// | |
// Created by Stephan Casas on 3/29/24. | |
// | |
import SwiftUI; | |
import Combine; | |
class DumbHTTPServer: ObservableObject { | |
@Published var isListening: Bool = false; | |
@Published var responseStatus: HTTPStatusCode = .ok; | |
@Published var responseBody: String = "OK"; | |
@Published var requestString: String = ""; | |
@Published var port: Int; | |
private let backlog: Int32; | |
private let requestBufferSize: Int; | |
private var serverThread: Thread? = nil; | |
private var subscriptions = [AnyCancellable](); | |
static let defaultPort: Int = 4444; | |
init(port: Int, backlog: Int32 = 10, requestBufferSize: Int = 1024) { | |
self.port = port; | |
self.backlog = backlog; | |
self.requestBufferSize = requestBufferSize; | |
self.$isListening.sink(receiveValue: { [weak self] in | |
self?.onIsListeningDidChange($0) | |
}).store(in: &self.subscriptions); | |
self.$port | |
.debounce(for: .milliseconds(500), scheduler: RunLoop.main) | |
.sink(receiveValue: { [weak self] in | |
self?.onPortNumberDidChange($0); | |
}).store(in: &self.subscriptions); | |
} | |
private func onPortNumberDidChange(_ newValue: Int) { | |
if !self.isListening { return } | |
self.hangup(); | |
self.listen(); | |
} | |
private func onIsListeningDidChange(_ newValue: Bool) { | |
guard newValue else { | |
return self.hangup(); | |
} | |
self.listen(); | |
} | |
private func listen() { | |
let listen_fd = socket(AF_INET, SOCK_STREAM, 0); | |
let servaddr = sockaddr_in( | |
sin_len: 0, | |
sin_family: UInt8(AF_INET), | |
sin_port: _OSSwapInt16(.init(self.port == 0 ? Self.defaultPort : self.port)), | |
sin_addr: .init(s_addr: _OSSwapInt32(INADDR_ANY)), | |
sin_zero: (0, 0, 0, 0, 0, 0, 0, 0)); | |
withUnsafePointer(to: unsafeBitCast(servaddr, to: sockaddr.self), { | |
let _ = bind(listen_fd, $0, UInt32(MemoryLayout.size(ofValue: servaddr))); | |
}); | |
Darwin.listen(listen_fd, self.backlog); | |
self.serverThread = .init(block: { [weak self] in | |
let requestBufferSize = self?.requestBufferSize ?? 1024; | |
var requestBuffer = [UInt8].init(repeating: 0, count: requestBufferSize); | |
while true { | |
let connection = accept(listen_fd, nil, nil); | |
requestBuffer.withContiguousMutableStorageIfAvailable({ | |
$0.initialize(repeating: 0); | |
read(connection, $0.baseAddress, requestBufferSize); | |
}); | |
let responseString = self?.responseString ?? ""; | |
responseString.utf8CString.withUnsafeBytes({ | |
let _ = write(connection, $0.baseAddress, responseString.utf8.count); | |
}); | |
close(connection); | |
guard self != nil else { break } | |
DispatchQueue.main.async(execute: { | |
self?.requestString = .init(cString: requestBuffer); | |
}) | |
} | |
}); | |
self.serverThread?.start(); | |
} | |
private func hangup() { | |
self.serverThread?.cancel(); | |
} | |
private var responseString: String { | |
""" | |
HTTP/1.1 \(self.responseStatus) | |
Content-Type: text/plain; charset=utf-8 | |
Connection: close | |
Content-Length: \(self.responseBody.count) | |
\(self.responseBody) | |
""" | |
} | |
deinit { | |
self.serverThread?.cancel(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// HTTPStatusCode.swift | |
// DumbHTTPServer | |
// | |
// Created by Stephan Casas on 3/29/24. | |
// | |
import Foundation | |
enum HTTPStatusCode: Int, Error, CaseIterable { | |
enum ResponseType { | |
case informational, success, redirection, clientError, serverError, undefined; | |
} | |
case `continue` = 100; | |
case switchingProtocols = 101; | |
case processing = 102; | |
case ok = 200; | |
case created = 201; | |
case accepted = 202; | |
case nonAuthoritativeInformation = 203; | |
case noContent = 204; | |
case resetContent = 205; | |
case partialContent = 206; | |
case multiStatus = 207; | |
case alreadyReported = 208; | |
case IMUsed = 226; | |
case multipleChoices = 300; | |
case movedPermanently = 301; | |
case found = 302; | |
case seeOther = 303; | |
case notModified = 304; | |
case useProxy = 305; | |
case switchProxy = 306; | |
case temporaryRedirect = 307; | |
case permanentRedirect = 308; | |
case badRequest = 400; | |
case unauthorized = 401; | |
case paymentRequired = 402; | |
case forbidden = 403; | |
case notFound = 404; | |
case methodNotAllowed = 405; | |
case notAcceptable = 406; | |
case proxyAuthenticationRequired = 407; | |
case requestTimeout = 408; | |
case conflict = 409; | |
case gone = 410; | |
case lengthRequired = 411; | |
case preconditionFailed = 412; | |
case payloadTooLarge = 413; | |
case URITooLong = 414; | |
case unsupportedMediaType = 415; | |
case rangeNotSatisfiable = 416; | |
case expectationFailed = 417; | |
case teapot = 418; | |
case misdirectedRequest = 421; | |
case unprocessableEntity = 422; | |
case locked = 423; | |
case failedDependency = 424; | |
case upgradeRequired = 426; | |
case preconditionRequired = 428; | |
case tooManyRequests = 429; | |
case requestHeaderFieldsTooLarge = 431; | |
case noResponse = 444; | |
case unavailableForLegalReasons = 451; | |
case SSLCertificateError = 495; | |
case SSLCertificateRequired = 496; | |
case HTTPRequestSentToHTTPSPort = 497; | |
case clientClosedRequest = 499; | |
case internalServerError = 500; | |
case notImplemented = 501; | |
case badGateway = 502; | |
case serviceUnavailable = 503; | |
case gatewayTimeout = 504; | |
case HTTPVersionNotSupported = 505; | |
case variantAlsoNegotiates = 506; | |
case insufficientStorage = 507; | |
case loopDetected = 508; | |
case notExtended = 510; | |
case networkAuthenticationRequired = 511; | |
var responseType: ResponseType { | |
switch self.rawValue { | |
case 100..<200: | |
return .informational | |
case 200..<300: | |
return .success | |
case 300..<400: | |
return .redirection | |
case 400..<500: | |
return .clientError | |
case 500..<600: | |
return .serverError | |
default: | |
return .undefined | |
} | |
} | |
} | |
extension HTTPURLResponse { | |
var status: HTTPStatusCode? { | |
return HTTPStatusCode(rawValue: statusCode) | |
} | |
} | |
extension HTTPStatusCode: CustomStringConvertible { | |
var description: String { | |
"\(self.rawValue) \(self.disposition)" | |
} | |
var disposition: String { | |
switch self { | |
case .continue: | |
"Continue" | |
case .switchingProtocols: | |
"Switching Protocols" | |
case .processing: | |
"Processing" | |
case .ok: | |
"OK" | |
case .created: | |
"Created" | |
case .accepted: | |
"Accepted" | |
case .nonAuthoritativeInformation: | |
"Non-Authoritative Information" | |
case .noContent: | |
"No Content" | |
case .resetContent: | |
"Reset Content" | |
case .partialContent: | |
"Partial Content" | |
case .multiStatus: | |
"Multi-Status" | |
case .alreadyReported: | |
"Already Reported" | |
case .IMUsed: | |
"IM Used" | |
case .multipleChoices: | |
"Multiple Choices" | |
case .movedPermanently: | |
"Moved Permanently" | |
case .found: | |
"Found" | |
case .seeOther: | |
"See Other" | |
case .notModified: | |
"Not Modified" | |
case .useProxy: | |
"Use Proxy" | |
case .switchProxy: | |
"Switch Proxy" | |
case .temporaryRedirect: | |
"Temporary Redirect" | |
case .permanentRedirect: | |
"Permanent Redirect" | |
case .badRequest: | |
"Bad Request" | |
case .unauthorized: | |
"Unauthorized" | |
case .paymentRequired: | |
"Payment Required" | |
case .forbidden: | |
"Forbidden" | |
case .notFound: | |
"Not Found" | |
case .methodNotAllowed: | |
"Method Not Allowed" | |
case .notAcceptable: | |
"Not Acceptable" | |
case .proxyAuthenticationRequired: | |
"Proxy Authentication Required" | |
case .requestTimeout: | |
"Request Timeout" | |
case .conflict: | |
"Conflict" | |
case .gone: | |
"Gone" | |
case .lengthRequired: | |
"Length Required" | |
case .preconditionFailed: | |
"Precondition Failed" | |
case .payloadTooLarge: | |
"Payload Too Large" | |
case .URITooLong: | |
"URI Too Long" | |
case .unsupportedMediaType: | |
"Unsupported Media Type" | |
case .rangeNotSatisfiable: | |
"Range Not Satisfiable" | |
case .expectationFailed: | |
"Expectation Failed" | |
case .teapot: | |
"I'm a Teapot" | |
case .misdirectedRequest: | |
"Misdirected Request" | |
case .unprocessableEntity: | |
"Unprocessable Entity" | |
case .locked: | |
"Locked" | |
case .failedDependency: | |
"Failed Dependency" | |
case .upgradeRequired: | |
"Upgrade Required" | |
case .preconditionRequired: | |
"Precondition Required" | |
case .tooManyRequests: | |
"Too Many Requests" | |
case .requestHeaderFieldsTooLarge: | |
"Request Header Fields Too Large" | |
case .noResponse: | |
"No Response" | |
case .unavailableForLegalReasons: | |
"Unavailable for Legal Reasons" | |
case .SSLCertificateError: | |
"SSL Certificate Error" | |
case .SSLCertificateRequired: | |
"SSL Certificate Required" | |
case .HTTPRequestSentToHTTPSPort: | |
"HTTP Request Sent to HTTPS Port" | |
case .clientClosedRequest: | |
"Client Closed Request" | |
case .internalServerError: | |
"Internal Server Error" | |
case .notImplemented: | |
"Not Implemented" | |
case .badGateway: | |
"Bad Gateway" | |
case .serviceUnavailable: | |
"Service Unavailable" | |
case .gatewayTimeout: | |
"Gateway Timeout" | |
case .HTTPVersionNotSupported: | |
"HTTP Version Not Supported" | |
case .variantAlsoNegotiates: | |
"Variant Also Negotiates" | |
case .insufficientStorage: | |
"Insufficient Storage" | |
case .loopDetected: | |
"Loop Detected" | |
case .notExtended: | |
"Not Extended" | |
case .networkAuthenticationRequired: | |
"Network Authentication Required" | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment