@cappitolian/http-local-server 0.0.6 → 0.0.8

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.
@@ -13,5 +13,6 @@ Pod::Spec.new do |s|
13
13
  s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
14
  s.ios.deployment_target = '14.0'
15
15
  s.dependency 'Capacitor'
16
+ s.dependency 'GCDWebServer', '~> 3.0'
16
17
  s.swift_version = '5.1'
17
18
  end
@@ -20,104 +20,75 @@ import Foundation
20
20
  import GCDWebServer
21
21
  import Capacitor
22
22
 
23
+ // MARK: - Protocol
23
24
  public protocol HttpLocalServerDelegate: AnyObject {
24
25
  func httpLocalServerDidReceiveRequest(_ data: [String: Any])
25
26
  }
26
27
 
28
+ // MARK: - HttpLocalServer
27
29
  @objc public class HttpLocalServer: NSObject {
28
- var webServer: GCDWebServer?
29
- weak var delegate: HttpLocalServerDelegate?
30
- static var pendingResponses = [String: (String) -> Void]()
31
- static let queue = DispatchQueue(label: "HttpLocalServer.pendingResponses")
32
-
30
+ // MARK: - Properties
31
+ private var webServer: GCDWebServer?
32
+ private weak var delegate: HttpLocalServerDelegate?
33
+
34
+ private static var pendingResponses = [String: (String) -> Void]()
35
+ private static let queue = DispatchQueue(label: "com.cappitolian.HttpLocalServer.pendingResponses", qos: .userInitiated)
36
+
37
+ private let defaultTimeout: TimeInterval = 5.0
38
+ private let defaultPort: UInt = 8080
39
+
40
+ // MARK: - Initialization
33
41
  public init(delegate: HttpLocalServerDelegate) {
34
42
  self.delegate = delegate
43
+ super.init()
35
44
  }
36
-
45
+
46
+ deinit {
47
+ disconnect()
48
+ }
49
+
50
+ // MARK: - Public Methods
37
51
  @objc public func connect(_ call: CAPPluginCall) {
38
52
  DispatchQueue.main.async { [weak self] in
39
- guard let self = self else { return }
40
-
53
+ guard let self = self else {
54
+ call.reject("Server instance deallocated")
55
+ return
56
+ }
57
+
58
+ // Stop existing server if running
59
+ if self.webServer?.isRunning == true {
60
+ self.webServer?.stop()
61
+ }
62
+
41
63
  self.webServer = GCDWebServer()
42
-
43
- self.webServer?.addHandler(
44
- match: { method, url, headers, path, query in
45
- GCDWebServerRequest(method: method, url: url, headers: headers, path: path, query: query)
46
- },
47
- processBlock: { request in
48
- let method = request.method
49
- let path = request.url.path
50
- var body: String? = nil
51
-
52
- if let dataRequest = request as? GCDWebServerDataRequest, let text = String(data: dataRequest.data, encoding: .utf8) {
53
- body = text
54
- }
55
-
56
- let requestId = UUID().uuidString
57
- var responseString: String? = nil
58
-
59
- // Set up a semaphore so we can block until JS responds or timeout (3s)
60
- let semaphore = DispatchSemaphore(value: 0)
61
- Self.queue.async {
62
- Self.pendingResponses[requestId] = { responseBody in
63
- responseString = responseBody
64
- semaphore.signal()
65
- }
66
- }
67
-
68
- // Notify delegate (plugin) with the request info
69
- let req: [String: Any?] = [
70
- "requestId": requestId,
71
- "method": method,
72
- "path": path,
73
- "body": body
74
- ]
75
- self.delegate?.httpLocalServerDidReceiveRequest(req.compactMapValues { $0 })
76
-
77
- // Wait for JS response or timeout
78
- _ = semaphore.wait(timeout: .now() + 3.0)
79
- Self.queue.async {
80
- Self.pendingResponses.removeValue(forKey: requestId)
81
- }
82
- let reply = responseString ?? "{\"error\":\"Timeout waiting for JS response\"}"
83
-
84
- let response = GCDWebServerDataResponse(text: reply)
85
- response?.setValue("*", forAdditionalHeader: "Access-Control-Allow-Origin")
86
- response?.setValue("GET,POST,OPTIONS", forAdditionalHeader: "Access-Control-Allow-Methods")
87
- response?.setValue("origin, content-type, accept, authorization", forAdditionalHeader: "Access-Control-Allow-Headers")
88
- response?.setValue("3600", forAdditionalHeader: "Access-Control-Max-Age")
89
- response?.contentType = "application/json"
90
- return response!
91
- }
92
- )
93
-
94
- let port: UInt = 8080
64
+ self.setupHandlers()
65
+
95
66
  do {
96
- try self.webServer?.start(options: [
97
- GCDWebServerOption_Port: port,
98
- GCDWebServerOption_BonjourName: "",
99
- GCDWebServerOption_BindToLocalhost: false
100
- ])
67
+ try self.startServer()
101
68
  let ip = Self.getWiFiAddress() ?? "127.0.0.1"
102
69
  call.resolve([
103
70
  "ip": ip,
104
- "port": port
71
+ "port": self.defaultPort
105
72
  ])
106
73
  } catch {
107
74
  call.reject("Failed to start server: \(error.localizedDescription)")
108
75
  }
109
76
  }
110
77
  }
111
-
112
- @objc public func disconnect(_ call: CAPPluginCall) {
78
+
79
+ @objc public func disconnect(_ call: CAPPluginCall? = nil) {
113
80
  DispatchQueue.main.async { [weak self] in
114
- self?.webServer?.stop()
115
- self?.webServer = nil
116
- call.resolve()
81
+ guard let self = self else {
82
+ call?.reject("Server instance deallocated")
83
+ return
84
+ }
85
+
86
+ self.disconnect()
87
+ call?.resolve()
117
88
  }
118
89
  }
119
-
120
- // Called by plugin when JS responds
90
+
91
+ // MARK: - Static Methods
121
92
  static func handleJsResponse(requestId: String, body: String) {
122
93
  queue.async {
123
94
  if let callback = pendingResponses[requestId] {
@@ -126,32 +97,237 @@ public protocol HttpLocalServerDelegate: AnyObject {
126
97
  }
127
98
  }
128
99
  }
129
-
130
- // Helper: get WiFi IP address (IPv4)
100
+
101
+ // MARK: - Private Methods
102
+ private func disconnect() {
103
+ webServer?.stop()
104
+ webServer = nil
105
+
106
+ // Clear pending responses
107
+ Self.queue.async {
108
+ Self.pendingResponses.removeAll()
109
+ }
110
+ }
111
+
112
+ private func setupHandlers() {
113
+ guard let webServer = webServer else { return }
114
+
115
+ // GET requests
116
+ webServer.addDefaultHandler(
117
+ forMethod: "GET",
118
+ request: GCDWebServerRequest.self,
119
+ processBlock: { [weak self] request in
120
+ return self?.processRequest(request) ?? self?.errorResponse() ?? GCDWebServerResponse()
121
+ }
122
+ )
123
+
124
+ // POST requests (with body)
125
+ webServer.addDefaultHandler(
126
+ forMethod: "POST",
127
+ request: GCDWebServerDataRequest.self,
128
+ processBlock: { [weak self] request in
129
+ return self?.processRequest(request) ?? self?.errorResponse() ?? GCDWebServerResponse()
130
+ }
131
+ )
132
+
133
+ // PUT requests (with body)
134
+ webServer.addDefaultHandler(
135
+ forMethod: "PUT",
136
+ request: GCDWebServerDataRequest.self,
137
+ processBlock: { [weak self] request in
138
+ return self?.processRequest(request) ?? self?.errorResponse() ?? GCDWebServerResponse()
139
+ }
140
+ )
141
+
142
+ // PATCH requests (with body)
143
+ webServer.addDefaultHandler(
144
+ forMethod: "PATCH",
145
+ request: GCDWebServerDataRequest.self,
146
+ processBlock: { [weak self] request in
147
+ return self?.processRequest(request) ?? self?.errorResponse() ?? GCDWebServerResponse()
148
+ }
149
+ )
150
+
151
+ // DELETE requests
152
+ webServer.addDefaultHandler(
153
+ forMethod: "DELETE",
154
+ request: GCDWebServerRequest.self,
155
+ processBlock: { [weak self] request in
156
+ return self?.processRequest(request) ?? self?.errorResponse() ?? GCDWebServerResponse()
157
+ }
158
+ )
159
+
160
+ // OPTIONS requests (CORS preflight)
161
+ webServer.addDefaultHandler(
162
+ forMethod: "OPTIONS",
163
+ request: GCDWebServerRequest.self,
164
+ processBlock: { [weak self] request in
165
+ return self?.corsResponse() ?? GCDWebServerResponse()
166
+ }
167
+ )
168
+ }
169
+
170
+ private func processRequest(_ request: GCDWebServerRequest) -> GCDWebServerResponse {
171
+ let method = request.method
172
+ let path = request.url.path
173
+ let body = extractBody(from: request)
174
+ let headers = request.headers
175
+ let query = request.query
176
+
177
+ let requestId = UUID().uuidString
178
+ var responseString: String?
179
+
180
+ // Setup semaphore for synchronous waiting
181
+ let semaphore = DispatchSemaphore(value: 0)
182
+
183
+ Self.queue.async {
184
+ Self.pendingResponses[requestId] = { responseBody in
185
+ responseString = responseBody
186
+ semaphore.signal()
187
+ }
188
+ }
189
+
190
+ // Notify delegate with request info
191
+ var requestData: [String: Any] = [
192
+ "requestId": requestId,
193
+ "method": method,
194
+ "path": path
195
+ ]
196
+
197
+ if let body = body {
198
+ requestData["body"] = body
199
+ }
200
+
201
+ if let headers = headers as? [String: String], !headers.isEmpty {
202
+ requestData["headers"] = headers
203
+ }
204
+
205
+ if let query = query, !query.isEmpty {
206
+ requestData["query"] = query
207
+ }
208
+
209
+ delegate?.httpLocalServerDidReceiveRequest(requestData)
210
+
211
+ // Wait for JS response or timeout
212
+ let result = semaphore.wait(timeout: .now() + defaultTimeout)
213
+
214
+ // Cleanup
215
+ Self.queue.async {
216
+ Self.pendingResponses.removeValue(forKey: requestId)
217
+ }
218
+
219
+ // Handle timeout
220
+ if result == .timedOut {
221
+ let timeoutResponse = "{\"error\":\"Request timeout\",\"requestId\":\"\(requestId)\"}"
222
+ return createJsonResponse(timeoutResponse, statusCode: 408)
223
+ }
224
+
225
+ let reply = responseString ?? "{\"error\":\"No response from handler\"}"
226
+ return createJsonResponse(reply)
227
+ }
228
+
229
+ private func extractBody(from request: GCDWebServerRequest) -> String? {
230
+ guard let dataRequest = request as? GCDWebServerDataRequest else {
231
+ return nil
232
+ }
233
+
234
+ return String(data: dataRequest.data, encoding: .utf8)
235
+ }
236
+
237
+ private func createJsonResponse(_ body: String, statusCode: Int = 200) -> GCDWebServerDataResponse {
238
+ let response = GCDWebServerDataResponse(text: body)
239
+ response?.statusCode = statusCode
240
+ response?.contentType = "application/json"
241
+
242
+ // CORS headers
243
+ response?.setValue("*", forAdditionalHeader: "Access-Control-Allow-Origin")
244
+ response?.setValue("GET, POST, PUT, PATCH, DELETE, OPTIONS", forAdditionalHeader: "Access-Control-Allow-Methods")
245
+ response?.setValue("Origin, Content-Type, Accept, Authorization", forAdditionalHeader: "Access-Control-Allow-Headers")
246
+ response?.setValue("true", forAdditionalHeader: "Access-Control-Allow-Credentials")
247
+ response?.setValue("3600", forAdditionalHeader: "Access-Control-Max-Age")
248
+
249
+ return response ?? GCDWebServerDataResponse()
250
+ }
251
+
252
+ private func corsResponse() -> GCDWebServerDataResponse {
253
+ return createJsonResponse("{}", statusCode: 204)
254
+ }
255
+
256
+ private func errorResponse() -> GCDWebServerDataResponse {
257
+ return createJsonResponse("{\"error\":\"Server error\"}", statusCode: 500)
258
+ }
259
+
260
+ private func startServer() throws {
261
+ guard let webServer = webServer else {
262
+ throw NSError(
263
+ domain: "HttpLocalServer",
264
+ code: -1,
265
+ userInfo: [NSLocalizedDescriptionKey: "WebServer not initialized"]
266
+ )
267
+ }
268
+
269
+ let options: [String: Any] = [
270
+ GCDWebServerOption_Port: defaultPort,
271
+ GCDWebServerOption_BonjourName: "",
272
+ GCDWebServerOption_BindToLocalhost: false,
273
+ GCDWebServerOption_AutomaticallySuspendInBackground: false
274
+ ]
275
+
276
+ try webServer.start(options: options)
277
+ }
278
+
279
+ // MARK: - Network Utilities
131
280
  static func getWiFiAddress() -> String? {
132
281
  var address: String?
133
282
  var ifaddr: UnsafeMutablePointer<ifaddrs>?
134
- if getifaddrs(&ifaddr) == 0 {
135
- var ptr = ifaddr
136
- while ptr != nil {
137
- let interface = ptr!.pointee
138
- let addrFamily = interface.ifa_addr.pointee.sa_family
139
- if addrFamily == UInt8(AF_INET) {
140
- let name = String(cString: interface.ifa_name)
141
- if name == "en0" { // WiFi interface
142
- var addr = interface.ifa_addr.pointee
143
- var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
144
- getnameinfo(&addr, socklen_t(interface.ifa_addr.pointee.sa_len),
145
- &hostname, socklen_t(hostname.count),
146
- nil, socklen_t(0), NI_NUMERICHOST)
147
- address = String(cString: hostname)
148
- break
149
- }
150
- }
151
- ptr = interface.ifa_next
152
- }
283
+
284
+ guard getifaddrs(&ifaddr) == 0 else {
285
+ return nil
286
+ }
287
+
288
+ defer {
153
289
  freeifaddrs(ifaddr)
154
290
  }
291
+
292
+ var ptr = ifaddr
293
+ while ptr != nil {
294
+ defer { ptr = ptr?.pointee.ifa_next }
295
+
296
+ guard let interface = ptr?.pointee else { continue }
297
+
298
+ let addrFamily = interface.ifa_addr.pointee.sa_family
299
+
300
+ // Check for IPv4 interface
301
+ guard addrFamily == UInt8(AF_INET) else { continue }
302
+
303
+ let name = String(cString: interface.ifa_name)
304
+
305
+ // WiFi interface (en0) or cellular (pdp_ip0)
306
+ guard name == "en0" || name == "pdp_ip0" else { continue }
307
+
308
+ var addr = interface.ifa_addr.pointee
309
+ var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
310
+
311
+ let result = getnameinfo(
312
+ &addr,
313
+ socklen_t(interface.ifa_addr.pointee.sa_len),
314
+ &hostname,
315
+ socklen_t(hostname.count),
316
+ nil,
317
+ 0,
318
+ NI_NUMERICHOST
319
+ )
320
+
321
+ guard result == 0 else { continue }
322
+
323
+ address = String(cString: hostname)
324
+
325
+ // Prefer en0 (WiFi) over pdp_ip0 (cellular)
326
+ if name == "en0" {
327
+ break
328
+ }
329
+ }
330
+
155
331
  return address
156
332
  }
157
- }
333
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cappitolian/http-local-server",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
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",