@cappitolian/http-local-server-swifter 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -219,6 +219,9 @@ public class HttpLocalServerSwifter {
219
219
  response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
220
220
  response.addHeader("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Requested-With");
221
221
  response.addHeader("Access-Control-Max-Age", "3600");
222
+ // Prevents TCP connection reuse. NanoHTTPD does not handle keep-alive
223
+ // correctly under rapid sequential requests, causing ERR_INVALID_HTTP_RESPONSE.
224
+ response.addHeader("Connection", "close");
222
225
  }
223
226
 
224
227
  private Response createErrorResponse(String message, Response.Status status) {
@@ -9,161 +9,201 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
9
9
  @objc public class HttpLocalServerSwifter: NSObject {
10
10
  private var webServer: HttpServer?
11
11
  private weak var delegate: HttpLocalServerSwifterDelegate?
12
-
12
+
13
13
  private static var pendingResponses = [String: (String) -> Void]()
14
- private static let queue = DispatchQueue(label: "com.cappitolian.HttpLocalServerSwifter.pendingResponses", qos: .userInitiated)
15
-
14
+ private static let queue = DispatchQueue(
15
+ label: "com.cappitolian.HttpLocalServerSwifter.pendingResponses",
16
+ qos: .userInitiated
17
+ )
18
+
16
19
  private let defaultTimeout: TimeInterval = 10.0
17
20
  private let defaultPort: UInt16 = 8080
18
-
21
+
19
22
  public init(delegate: HttpLocalServerSwifterDelegate) {
20
23
  self.delegate = delegate
21
24
  super.init()
22
25
  }
23
-
26
+
27
+ // MARK: - Public API
28
+
24
29
  @objc public func connect(_ call: CAPPluginCall) {
25
- // IMPORTANT: Move execution to a background thread immediately
26
30
  DispatchQueue.global(qos: .userInitiated).async { [weak self] in
27
- guard let self = self else { return }
28
-
29
- self.disconnect()
31
+ guard let self else { return }
32
+
33
+ self.stopServer()
34
+
30
35
  let server = HttpServer()
31
36
  self.webServer = server
32
-
33
- // Use middleware to catch ALL requests and avoid route misses
37
+
34
38
  server.middleware.append { [weak self] request in
39
+ guard let self else { return .raw(503, "Service Unavailable", nil, nil) }
40
+
35
41
  if request.method == "OPTIONS" {
36
- return self?.corsResponse() ?? .raw(204, "No Content", nil, nil)
42
+ return self.corsResponse()
37
43
  }
38
- return self?.processRequest(request) ?? .raw(500, "Internal Server Error", nil, nil)
44
+ return self.processRequest(request)
39
45
  }
40
-
46
+
41
47
  do {
42
- // We start the server. This call is non-blocking in Swifter but
43
- // it's safer to do it here.
44
48
  try server.start(self.defaultPort, forceIPv4: true)
45
49
  let ip = Self.getWiFiAddress() ?? "127.0.0.1"
46
-
50
+
47
51
  print("🚀 SWIFTER: Server running on http://\(ip):\(self.defaultPort)")
48
-
49
- // Resolve back to Angular
52
+
50
53
  call.resolve([
51
54
  "ip": ip,
52
55
  "port": Int(self.defaultPort)
53
56
  ])
54
57
  } catch {
55
58
  print("❌ SWIFTER ERROR: \(error)")
56
- call.reject("Could not start server")
59
+ call.reject("Could not start server: \(error.localizedDescription)")
57
60
  }
58
61
  }
59
62
  }
60
-
61
- @objc public func disconnect(_ call: CAPPluginCall? = nil) {
63
+
64
+ /// Stops the server and clears state. Does NOT resolve/reject any call.
65
+ @objc public func stopServer() {
62
66
  webServer?.stop()
63
67
  webServer = nil
64
- Self.queue.async { Self.pendingResponses.removeAll() }
65
- call?.resolve()
68
+ // Drain pending futures so blocked threads can unblock on their semaphore timeout.
69
+ Self.queue.sync { Self.pendingResponses.removeAll() }
70
+ }
71
+
72
+ /// Stops the server and resolves the Capacitor call.
73
+ @objc public func disconnect(resolving call: CAPPluginCall) {
74
+ stopServer()
75
+ call.resolve()
66
76
  }
67
77
 
78
+ // MARK: - JS → Native response bridge
79
+
80
+ static func handleJsResponse(requestId: String, responseData: [String: Any]) {
81
+ // sync: guarantees the callback executes (and signals the semaphore) before
82
+ // this function returns, which eliminates the race condition where the
83
+ // semaphore wait could time out while the callback is still queued.
84
+ queue.sync {
85
+ guard let callback = pendingResponses.removeValue(forKey: requestId) else { return }
86
+
87
+ guard
88
+ let jsonData = try? JSONSerialization.data(withJSONObject: responseData),
89
+ let jsonString = String(data: jsonData, encoding: .utf8)
90
+ else { return }
91
+
92
+ callback(jsonString)
93
+ }
94
+ }
95
+
96
+ // MARK: - Request processing
97
+
68
98
  private func processRequest(_ request: HttpRequest) -> HttpResponse {
69
99
  let requestId = UUID().uuidString
100
+
101
+ // Use a protected local variable written only inside `queue.sync` inside
102
+ // the callback, and read only after the semaphore is signalled — the
103
+ // happens-before guarantee of DispatchSemaphore makes this safe.
70
104
  var responseString: String?
71
105
  let semaphore = DispatchSemaphore(value: 0)
72
-
73
- Self.queue.async {
106
+
107
+ Self.queue.sync {
74
108
  Self.pendingResponses[requestId] = { jsResponse in
75
109
  responseString = jsResponse
76
110
  semaphore.signal()
77
111
  }
78
112
  }
79
-
113
+
80
114
  let requestData: [String: Any] = [
81
115
  "requestId": requestId,
82
- "method": request.method,
83
- "path": request.path,
84
- "headers": request.headers,
85
- "query": request.queryParams,
86
- "body": String(bytes: request.body, encoding: .utf8) ?? ""
116
+ "method": request.method,
117
+ "path": request.path,
118
+ "headers": request.headers,
119
+ "query": request.queryParams,
120
+ "body": String(bytes: request.body, encoding: .utf8) ?? ""
87
121
  ]
88
-
89
- // CRITICAL: notifyListeners MUST be called from the Main Thread
122
+
123
+ // notifyListeners MUST be called from the main thread.
90
124
  DispatchQueue.main.async {
91
125
  self.delegate?.httpLocalServerSwifterDidReceiveRequest(requestData)
92
126
  }
93
-
127
+
94
128
  let result = semaphore.wait(timeout: .now() + defaultTimeout)
95
-
129
+
96
130
  if result == .timedOut {
97
- Self.queue.async { Self.pendingResponses.removeValue(forKey: requestId) }
131
+ // Remove callback in case handleJsResponse fires late.
132
+ Self.queue.sync { Self.pendingResponses.removeValue(forKey: requestId) }
98
133
  return .raw(408, "Request Timeout", nil, nil)
99
134
  }
100
-
135
+
101
136
  return createDynamicResponse(responseString ?? "")
102
137
  }
103
138
 
104
- static func handleJsResponse(requestId: String, responseData: [String: Any]) {
105
- queue.async {
106
- if let callback = pendingResponses[requestId] {
107
- // Extract body, status and headers to pass as a JSON string to the semaphore
108
- if let jsonData = try? JSONSerialization.data(withJSONObject: responseData),
109
- let jsonString = String(data: jsonData, encoding: .utf8) {
110
- callback(jsonString)
111
- }
112
- pendingResponses.removeValue(forKey: requestId)
113
- }
114
- }
115
- }
139
+ // MARK: - Response builders
116
140
 
117
141
  private func createDynamicResponse(_ jsonResponse: String) -> HttpResponse {
118
142
  var finalStatus = 200
119
- var finalBody = jsonResponse
143
+ var finalBody = jsonResponse
120
144
  var headers: [String: String] = [
121
- "Content-Type": "application/json",
122
- "Access-Control-Allow-Origin": "*",
123
- "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
124
- "Access-Control-Allow-Headers": "Origin, Content-Type, Accept, Authorization, X-Requested-With"
145
+ "Content-Type": "application/json",
146
+ "Access-Control-Allow-Origin": "*",
147
+ "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
148
+ "Access-Control-Allow-Headers": "Origin, Content-Type, Accept, Authorization, X-Requested-With"
125
149
  ]
126
-
127
- if let data = jsonResponse.data(using: .utf8),
128
- let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
129
- finalBody = dict["body"] as? String ?? ""
130
- finalStatus = dict["status"] as? Int ?? 200
150
+
151
+ if
152
+ let data = jsonResponse.data(using: .utf8),
153
+ let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
154
+ {
155
+ finalBody = dict["body"] as? String ?? ""
156
+ finalStatus = dict["status"] as? Int ?? 200
157
+
131
158
  if let customHeaders = dict["headers"] as? [String: String] {
132
159
  for (key, value) in customHeaders { headers[key] = value }
133
160
  }
134
161
  }
135
-
136
- return .raw(finalStatus, "OK", headers) { try $0.write([UInt8](finalBody.utf8)) }
162
+
163
+ return .raw(finalStatus, "OK", headers) { writer in
164
+ try writer.write([UInt8](finalBody.utf8))
165
+ }
137
166
  }
138
167
 
139
168
  private func corsResponse() -> HttpResponse {
140
169
  return .raw(204, "No Content", [
141
- "Access-Control-Allow-Origin": "*",
170
+ "Access-Control-Allow-Origin": "*",
142
171
  "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
143
172
  "Access-Control-Allow-Headers": "Origin, Content-Type, Accept, Authorization, X-Requested-With",
144
- "Access-Control-Max-Age": "86400"
173
+ "Access-Control-Max-Age": "86400"
145
174
  ], nil)
146
175
  }
147
176
 
177
+ // MARK: - Network helpers
178
+
148
179
  static func getWiFiAddress() -> String? {
149
180
  var address: String?
150
181
  var ifaddr: UnsafeMutablePointer<ifaddrs>?
151
- if getifaddrs(&ifaddr) == 0 {
152
- var ptr = ifaddr
153
- while ptr != nil {
154
- let interface = ptr!.pointee
155
- if interface.ifa_addr.pointee.sa_family == UInt8(AF_INET) {
156
- let name = String(cString: interface.ifa_name)
157
- if name == "en0" {
158
- var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
159
- getnameinfo(interface.ifa_addr, socklen_t(interface.ifa_addr.pointee.sa_len), &hostname, socklen_t(hostname.count), nil, 0, NI_NUMERICHOST)
160
- address = String(cString: hostname)
161
- }
162
- }
163
- ptr = interface.ifa_next
182
+
183
+ guard getifaddrs(&ifaddr) == 0 else { return nil }
184
+ defer { freeifaddrs(ifaddr) }
185
+
186
+ var ptr = ifaddr
187
+ while let current = ptr {
188
+ let interface = current.pointee
189
+ if interface.ifa_addr.pointee.sa_family == UInt8(AF_INET),
190
+ String(cString: interface.ifa_name) == "en0"
191
+ {
192
+ var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
193
+ getnameinfo(
194
+ interface.ifa_addr,
195
+ socklen_t(interface.ifa_addr.pointee.sa_len),
196
+ &hostname,
197
+ socklen_t(hostname.count),
198
+ nil, 0,
199
+ NI_NUMERICHOST
200
+ )
201
+ address = String(cString: hostname)
202
+ break
164
203
  }
165
- freeifaddrs(ifaddr)
204
+ ptr = interface.ifa_next
166
205
  }
206
+
167
207
  return address
168
208
  }
169
209
  }
@@ -3,42 +3,49 @@ import Capacitor
3
3
 
4
4
  @objc(HttpLocalServerSwifterPlugin)
5
5
  public class HttpLocalServerSwifterPlugin: CAPPlugin, CAPBridgedPlugin, HttpLocalServerSwifterDelegate {
6
- public let identifier = "HttpLocalServerSwifterPlugin"
7
- public let jsName = "HttpLocalServerSwifter"
6
+ public let identifier = "HttpLocalServerSwifterPlugin"
7
+ public let jsName = "HttpLocalServerSwifter"
8
8
  public let pluginMethods: [CAPPluginMethod] = [
9
- CAPPluginMethod(name: "connect", returnType: CAPPluginReturnPromise),
10
- CAPPluginMethod(name: "disconnect", returnType: CAPPluginReturnPromise),
9
+ CAPPluginMethod(name: "connect", returnType: CAPPluginReturnPromise),
10
+ CAPPluginMethod(name: "disconnect", returnType: CAPPluginReturnPromise),
11
11
  CAPPluginMethod(name: "sendResponse", returnType: CAPPluginReturnPromise)
12
12
  ]
13
-
13
+
14
14
  private var localServer: HttpLocalServerSwifter?
15
-
15
+
16
+ // MARK: - Plugin methods
17
+
16
18
  @objc func connect(_ call: CAPPluginCall) {
17
- if localServer == nil { localServer = HttpLocalServerSwifter(delegate: self) }
19
+ if localServer == nil {
20
+ localServer = HttpLocalServerSwifter(delegate: self)
21
+ }
18
22
  localServer?.connect(call)
19
23
  }
20
-
24
+
21
25
  @objc func disconnect(_ call: CAPPluginCall) {
22
- localServer?.disconnect(call)
26
+ // Use the dedicated method that accepts a call to resolve so the
27
+ // ambiguous no-argument overload is gone entirely.
28
+ localServer?.disconnect(resolving: call)
23
29
  localServer = nil
24
30
  }
25
-
26
- @objc func sendResponse(_ call: CAPPluginCall) {
27
- guard let requestId = call.getString("requestId") else {
28
- call.reject("Missing requestId")
29
- return
30
- }
31
-
32
- // Cast dictionaryRepresentation explicitly to [String: Any]
33
- if let responseData = call.dictionaryRepresentation as? [String: Any] {
34
- HttpLocalServerSwifter.handleJsResponse(requestId: requestId, responseData: responseData)
35
- call.resolve()
36
- } else {
37
- call.reject("Could not parse response data")
38
- }
39
- }
40
-
31
+
32
+ @objc func sendResponse(_ call: CAPPluginCall) {
33
+ guard let requestId = call.getString("requestId"), !requestId.isEmpty else {
34
+ call.reject("Missing requestId")
35
+ return
36
+ }
37
+
38
+ // dictionaryRepresentation already returns [String: Any] — no cast needed.
39
+ HttpLocalServerSwifter.handleJsResponse(
40
+ requestId: requestId,
41
+ responseData: call.dictionaryRepresentation
42
+ )
43
+ call.resolve()
44
+ }
45
+
46
+ // MARK: - Delegate
47
+
41
48
  public func httpLocalServerSwifterDidReceiveRequest(_ data: [String: Any]) {
42
49
  notifyListeners("onRequest", data: data)
43
50
  }
44
- }
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cappitolian/http-local-server-swifter",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "description": "Runs a local HTTP server on your device, accessible over LAN. Supports connect, disconnect, GET, and POST methods with IP and port discovery.",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",