@cappitolian/http-local-server-swifter 0.0.29 → 0.0.31

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,7 +13,6 @@ import com.getcapacitor.JSObject;
13
13
  import com.getcapacitor.Plugin;
14
14
  import com.getcapacitor.PluginCall;
15
15
 
16
- import org.json.JSONException;
17
16
  import org.json.JSONObject;
18
17
 
19
18
  import java.io.IOException;
@@ -23,72 +22,108 @@ import java.util.UUID;
23
22
  import java.util.concurrent.CompletableFuture;
24
23
  import java.util.concurrent.ConcurrentHashMap;
25
24
  import java.util.concurrent.TimeUnit;
26
- import java.util.concurrent.TimeoutException;
27
25
 
28
26
  import fi.iki.elonen.NanoHTTPD;
29
27
 
30
28
  public class HttpLocalServerSwifter {
29
+ // Private static final properties
31
30
  private static final String TAG = "HttpLocalServerSwifter";
32
31
  private static final int DEFAULT_PORT = 8080;
33
32
  private static final int DEFAULT_TIMEOUT_SECONDS = 5;
34
33
  private static final String FALLBACK_IP = "127.0.0.1";
35
-
36
- private LocalNanoServer server;
34
+ // Changed to String to transport the full JSON response from JS
35
+ private static final ConcurrentHashMap<String, CompletableFuture<String>> pendingResponses = new ConcurrentHashMap<>();
36
+ // Add inside LocalNanoServer
37
+ private static final ConcurrentHashMap<String, long[]> rateLimitMap = new ConcurrentHashMap<>();
38
+ private static final int RATE_LIMIT = 30; // requests
39
+ private static final long RATE_WINDOW_MS = 60_000; // per minute
40
+
41
+ // Private final properties
37
42
  private final Plugin plugin;
38
43
  private final int port;
39
44
  private final int timeoutSeconds;
40
-
41
- // Changed to String to transport the full JSON response from JS
42
- private static final ConcurrentHashMap<String, CompletableFuture<String>> pendingResponses = new ConcurrentHashMap<>();
43
-
45
+
46
+ // Private properties
47
+ private LocalNanoServer server;
48
+
49
+ private static boolean isRateLimited(String ip) {
50
+ long now = System.currentTimeMillis();
51
+
52
+ rateLimitMap.compute(ip, (key, timestamps) -> {
53
+ if (timestamps == null)
54
+ timestamps = new long[] { now, 1 };
55
+ else if (now - timestamps[0] > RATE_WINDOW_MS) {
56
+ timestamps[0] = now;
57
+ timestamps[1] = 1;
58
+ } else
59
+ timestamps[1]++;
60
+
61
+ return timestamps;
62
+ });
63
+
64
+ long[] entry = rateLimitMap.get(ip);
65
+
66
+ return entry != null && entry[1] > RATE_LIMIT;
67
+ }
68
+
44
69
  public HttpLocalServerSwifter(@NonNull Plugin plugin) {
45
70
  this(plugin, DEFAULT_PORT, DEFAULT_TIMEOUT_SECONDS);
46
71
  }
47
-
72
+
48
73
  public HttpLocalServerSwifter(@NonNull Plugin plugin, int port, int timeoutSeconds) {
49
74
  this.plugin = plugin;
50
75
  this.port = port;
51
76
  this.timeoutSeconds = timeoutSeconds;
52
77
  }
53
-
78
+
54
79
  public void connect(@NonNull PluginCall call) {
55
80
  if (server != null && server.isAlive()) {
56
81
  call.reject("Server is already running");
82
+
57
83
  return;
58
84
  }
59
-
85
+
60
86
  try {
61
87
  String localIp = getLocalIpAddress(plugin.getContext());
88
+
62
89
  server = new LocalNanoServer(localIp, port, plugin, timeoutSeconds);
63
90
  server.start();
64
-
91
+
65
92
  JSObject response = new JSObject();
93
+
66
94
  response.put("ip", localIp);
67
95
  response.put("port", port);
96
+
68
97
  call.resolve(response);
69
-
98
+
70
99
  Log.i(TAG, "Server started at " + localIp + ":" + port);
71
100
  } catch (IOException e) {
72
101
  Log.e(TAG, "Failed to start server", e);
102
+
73
103
  call.reject("Failed to start server: " + e.getMessage());
74
104
  }
75
105
  }
76
-
106
+
77
107
  public void disconnect(@Nullable PluginCall call) {
78
108
  if (server != null) {
79
109
  server.stop();
110
+
80
111
  server = null;
112
+
81
113
  pendingResponses.clear();
114
+
82
115
  Log.i(TAG, "Server stopped");
83
116
  }
84
- if (call != null) call.resolve();
117
+ if (call != null)
118
+ call.resolve();
85
119
  }
86
-
120
+
87
121
  /**
88
122
  * Completes the future with the full JS response object (body, status, headers)
89
123
  */
90
124
  public static void handleJsResponse(@NonNull String requestId, @NonNull JSObject responseData) {
91
125
  CompletableFuture<String> future = pendingResponses.remove(requestId);
126
+
92
127
  if (future != null && !future.isDone()) {
93
128
  future.complete(responseData.toString());
94
129
  Log.d(TAG, "Response object delivered to future for ID: " + requestId);
@@ -97,11 +132,19 @@ public class HttpLocalServerSwifter {
97
132
 
98
133
  private @NonNull String getLocalIpAddress(@NonNull Context context) {
99
134
  try {
100
- WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
101
- if (wifiManager == null) return FALLBACK_IP;
135
+ WifiManager wifiManager = (WifiManager) context.getApplicationContext()
136
+ .getSystemService(Context.WIFI_SERVICE);
137
+
138
+ if (wifiManager == null)
139
+ return FALLBACK_IP;
140
+
102
141
  WifiInfo wifiInfo = wifiManager.getConnectionInfo();
103
- if (wifiInfo == null) return FALLBACK_IP;
142
+
143
+ if (wifiInfo == null)
144
+ return FALLBACK_IP;
145
+
104
146
  int ipAddress = wifiInfo.getIpAddress();
147
+
105
148
  return ipAddress == 0 ? FALLBACK_IP : Formatter.formatIpAddress(ipAddress);
106
149
  } catch (Exception e) {
107
150
  return FALLBACK_IP;
@@ -111,27 +154,46 @@ public class HttpLocalServerSwifter {
111
154
  private static class LocalNanoServer extends NanoHTTPD {
112
155
  private final Plugin plugin;
113
156
  private final int timeoutSeconds;
114
-
157
+
115
158
  public LocalNanoServer(@NonNull String hostname, int port, @NonNull Plugin plugin, int timeoutSeconds) {
116
159
  super(hostname, port);
117
160
  this.plugin = plugin;
118
161
  this.timeoutSeconds = timeoutSeconds;
119
162
  }
120
-
163
+
121
164
  @Override
122
165
  public Response serve(@NonNull IHTTPSession session) {
166
+ if (Method.OPTIONS.equals(session.getMethod())) {
167
+ return createCorsResponse();
168
+ }
169
+
170
+ // Rate limiting
171
+ String clientIp = session.getHeaders().getOrDefault("http-client-ip",
172
+ session.getHeaders().getOrDefault("remote-addr", "unknown"));
173
+
174
+ if (isRateLimited(clientIp)) {
175
+ Response r = newFixedLengthResponse(
176
+ Response.Status.lookup(429), "application/json",
177
+ "{\"success\":false,\"error\":\"Too many requests\"}");
178
+
179
+ addCorsHeaders(r);
180
+
181
+ return r;
182
+ }
183
+
123
184
  // Native CORS Preflight handling for efficiency
124
185
  if (Method.OPTIONS.equals(session.getMethod())) {
125
186
  return createCorsResponse();
126
187
  }
127
-
188
+
128
189
  try {
129
190
  String method = session.getMethod().name();
130
191
  String path = session.getUri();
131
192
  String body = extractBody(session);
193
+
132
194
  Map<String, String> headers = session.getHeaders();
133
195
  Map<String, String> params = session.getParms();
134
-
196
+
135
197
  // Wait for TypeScript to process logic and provide the complex response
136
198
  String jsResponseRaw = processRequest(method, path, body, headers, params);
137
199
  return createDynamicResponse(jsResponseRaw);
@@ -141,24 +203,30 @@ public class HttpLocalServerSwifter {
141
203
  }
142
204
 
143
205
  /**
144
- * Parses the JSON from JS and builds a NanoHTTPD Response with custom status and headers
206
+ * Parses the JSON from JS and builds a NanoHTTPD Response with custom status
207
+ * and headers
145
208
  */
146
209
  private Response createDynamicResponse(String jsResponseRaw) {
147
210
  try {
148
211
  JSONObject res = new JSONObject(jsResponseRaw);
149
212
  String body = res.optString("body", "");
213
+
150
214
  int statusCode = res.optInt("status", 200);
215
+
151
216
  JSONObject customHeaders = res.optJSONObject("headers");
152
217
 
153
218
  Response.IStatus status = Response.Status.lookup(statusCode);
154
- Response response = newFixedLengthResponse(status != null ? status : Response.Status.OK, "application/json", body);
155
-
219
+ Response response = newFixedLengthResponse(status != null ? status : Response.Status.OK,
220
+ "application/json", body);
221
+
156
222
  // Add standard CORS headers
157
223
  addCorsHeaders(response);
158
224
 
159
- // Inject custom headers from TypeScript (allows overriding CORS or Content-Type)
225
+ // Inject custom headers from TypeScript (allows overriding CORS or
226
+ // Content-Type)
160
227
  if (customHeaders != null) {
161
228
  java.util.Iterator<String> keys = customHeaders.keys();
229
+
162
230
  while (keys.hasNext()) {
163
231
  String key = keys.next();
164
232
  response.addHeader(key, customHeaders.getString(key));
@@ -173,32 +241,39 @@ public class HttpLocalServerSwifter {
173
241
 
174
242
  private String extractBody(@NonNull IHTTPSession session) {
175
243
  Method method = session.getMethod();
176
- if (method != Method.POST && method != Method.PUT && method != Method.PATCH) return null;
244
+
245
+ if (method != Method.POST && method != Method.PUT && method != Method.PATCH)
246
+ return null;
247
+
177
248
  try {
178
249
  HashMap<String, String> files = new HashMap<>();
179
250
  session.parseBody(files);
180
251
  String body = files.get("postData");
252
+
181
253
  return (body == null || body.isEmpty()) ? session.getQueryParameterString() : body;
182
254
  } catch (IOException | ResponseException e) {
183
255
  return null;
184
256
  }
185
257
  }
186
-
187
- private String processRequest(String method, String path, String body, Map<String, String> headers, Map<String, String> params) {
258
+
259
+ private String processRequest(String method, String path, String body, Map<String, String> headers,
260
+ Map<String, String> params) {
188
261
  String requestId = UUID.randomUUID().toString();
189
262
  JSObject requestData = new JSObject();
190
263
  requestData.put("requestId", requestId);
191
264
  requestData.put("method", method);
192
265
  requestData.put("path", path);
193
- if (body != null) requestData.put("body", body);
194
-
266
+
267
+ if (body != null)
268
+ requestData.put("body", body);
269
+
195
270
  CompletableFuture<String> future = new CompletableFuture<>();
196
271
  pendingResponses.put(requestId, future);
197
-
272
+
198
273
  if (plugin instanceof HttpLocalServerSwifterPlugin) {
199
274
  ((HttpLocalServerSwifterPlugin) plugin).fireOnRequest(requestData);
200
275
  }
201
-
276
+
202
277
  try {
203
278
  return future.get(timeoutSeconds, TimeUnit.SECONDS);
204
279
  } catch (Exception e) {
@@ -210,14 +285,17 @@ public class HttpLocalServerSwifter {
210
285
 
211
286
  private Response createCorsResponse() {
212
287
  Response response = newFixedLengthResponse(Response.Status.NO_CONTENT, "text/plain", "");
288
+
213
289
  addCorsHeaders(response);
290
+
214
291
  return response;
215
292
  }
216
293
 
217
294
  private void addCorsHeaders(@NonNull Response response) {
218
295
  response.addHeader("Access-Control-Allow-Origin", "*");
219
296
  response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
220
- response.addHeader("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Requested-With");
297
+ response.addHeader("Access-Control-Allow-Headers",
298
+ "Origin, Content-Type, Accept, Authorization, X-Requested-With");
221
299
  response.addHeader("Access-Control-Max-Age", "3600");
222
300
  // Prevents TCP connection reuse. NanoHTTPD does not handle keep-alive
223
301
  // correctly under rapid sequential requests, causing ERR_INVALID_HTTP_RESPONSE.
@@ -29,14 +29,17 @@ public class HttpLocalServerSwifterPlugin extends Plugin {
29
29
  @PluginMethod
30
30
  public void sendResponse(PluginCall call) {
31
31
  String requestId = call.getString("requestId");
32
+
32
33
  if (requestId == null || requestId.isEmpty()) {
33
34
  call.reject("Missing requestId");
35
+
34
36
  return;
35
37
  }
36
-
38
+
37
39
  // Pass the entire PluginCall data object to handleJsResponse
38
40
  // This allows us to capture 'status' and 'headers' along with the 'body'
39
41
  HttpLocalServerSwifter.handleJsResponse(requestId, call.getData());
42
+
40
43
  call.resolve();
41
44
  }
42
45
 
@@ -7,20 +7,43 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
7
7
  }
8
8
 
9
9
  @objc public class HttpLocalServerSwifter: NSObject {
10
- private var webServer: HttpServer?
11
- private weak var delegate: HttpLocalServerSwifterDelegate?
12
-
10
+ // Private static properties
13
11
  private static var pendingResponses = [String: (String) -> Void]()
14
12
  private static let queue = DispatchQueue(
15
13
  label: "com.cappitolian.HttpLocalServerSwifter.pendingResponses",
16
14
  qos: .userInitiated
17
15
  )
18
16
 
17
+ // Private properties
18
+ private var webServer: HttpServer?
19
+ private weak var delegate: HttpLocalServerSwifterDelegate?
19
20
  private let defaultTimeout: TimeInterval = 10.0
20
21
  private let defaultPort: UInt16 = 8080
21
22
 
23
+ private var rateLimitMap = [String: (start: Date, count: Int)]()
24
+ private let rateLimitQueue = DispatchQueue(label: "rateLimit", qos: .userInitiated)
25
+ private let rateLimit = 30
26
+ private let rateWindowSeconds: TimeInterval = 60
27
+
28
+ private func isRateLimited(ip: String) -> Bool {
29
+ return rateLimitQueue.sync {
30
+ let now = Date()
31
+
32
+ if let entry = rateLimitMap[ip], now.timeIntervalSince(entry.start) < rateWindowSeconds {
33
+ if entry.count >= rateLimit { return true }
34
+
35
+ rateLimitMap[ip] = (entry.start, entry.count + 1)
36
+ } else {
37
+ rateLimitMap[ip] = (now, 1)
38
+ }
39
+
40
+ return false
41
+ }
42
+ }
43
+
22
44
  public init(delegate: HttpLocalServerSwifterDelegate) {
23
45
  self.delegate = delegate
46
+
24
47
  super.init()
25
48
  }
26
49
 
@@ -33,6 +56,7 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
33
56
  self.stopServer()
34
57
 
35
58
  let server = HttpServer()
59
+
36
60
  self.webServer = server
37
61
 
38
62
  server.middleware.append { [weak self] request in
@@ -41,11 +65,13 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
41
65
  if request.method == "OPTIONS" {
42
66
  return self.corsResponse()
43
67
  }
68
+
44
69
  return self.processRequest(request)
45
70
  }
46
71
 
47
72
  do {
48
73
  try server.start(self.defaultPort, forceIPv4: true)
74
+
49
75
  let ip = Self.getWiFiAddress() ?? "127.0.0.1"
50
76
 
51
77
  print("🚀 SWIFTER: Server running on http://\(ip):\(self.defaultPort)")
@@ -56,6 +82,7 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
56
82
  ])
57
83
  } catch {
58
84
  print("❌ SWIFTER ERROR: \(error)")
85
+
59
86
  call.reject("Could not start server: \(error.localizedDescription)")
60
87
  }
61
88
  }
@@ -65,6 +92,7 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
65
92
  @objc public func stopServer() {
66
93
  webServer?.stop()
67
94
  webServer = nil
95
+
68
96
  // Drain pending futures so blocked threads can unblock on their semaphore timeout.
69
97
  Self.queue.sync { Self.pendingResponses.removeAll() }
70
98
  }
@@ -72,6 +100,7 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
72
100
  /// Stops the server and resolves the Capacitor call.
73
101
  @objc public func disconnect(resolving call: CAPPluginCall) {
74
102
  stopServer()
103
+
75
104
  call.resolve()
76
105
  }
77
106
 
@@ -96,6 +125,18 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
96
125
  // MARK: - Request processing
97
126
 
98
127
  private func processRequest(_ request: HttpRequest) -> HttpResponse {
128
+ let clientIp = request.headers["x-forwarded-for"] ??
129
+ request.address ?? "unknown"
130
+
131
+ if isRateLimited(ip: clientIp) {
132
+ return .raw(429, "Too Many Requests", [
133
+ "Content-Type": "application/json",
134
+ "Access-Control-Allow-Origin": "*"
135
+ ]) { writer in
136
+ try writer.write([UInt8](#"{"success":false,"error":"Too many requests"}"#.utf8))
137
+ }
138
+ }
139
+
99
140
  let requestId = UUID().uuidString
100
141
 
101
142
  // Use a protected local variable written only inside `queue.sync` inside
@@ -184,12 +225,15 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
184
225
  defer { freeifaddrs(ifaddr) }
185
226
 
186
227
  var ptr = ifaddr
228
+
187
229
  while let current = ptr {
188
230
  let interface = current.pointee
231
+
189
232
  if interface.ifa_addr.pointee.sa_family == UInt8(AF_INET),
190
233
  String(cString: interface.ifa_name) == "en0"
191
234
  {
192
235
  var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
236
+
193
237
  getnameinfo(
194
238
  interface.ifa_addr,
195
239
  socklen_t(interface.ifa_addr.pointee.sa_len),
@@ -198,9 +242,12 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
198
242
  nil, 0,
199
243
  NI_NUMERICHOST
200
244
  )
245
+
201
246
  address = String(cString: hostname)
247
+
202
248
  break
203
249
  }
250
+
204
251
  ptr = interface.ifa_next
205
252
  }
206
253
 
@@ -3,6 +3,10 @@ import Capacitor
3
3
 
4
4
  @objc(HttpLocalServerSwifterPlugin)
5
5
  public class HttpLocalServerSwifterPlugin: CAPPlugin, CAPBridgedPlugin, HttpLocalServerSwifterDelegate {
6
+ // Private properties
7
+ private var localServer: HttpLocalServerSwifter?
8
+
9
+ // Publi properties
6
10
  public let identifier = "HttpLocalServerSwifterPlugin"
7
11
  public let jsName = "HttpLocalServerSwifter"
8
12
  public let pluginMethods: [CAPPluginMethod] = [
@@ -11,14 +15,13 @@ public class HttpLocalServerSwifterPlugin: CAPPlugin, CAPBridgedPlugin, HttpLoca
11
15
  CAPPluginMethod(name: "sendResponse", returnType: CAPPluginReturnPromise)
12
16
  ]
13
17
 
14
- private var localServer: HttpLocalServerSwifter?
15
-
16
18
  // MARK: - Plugin methods
17
19
 
18
20
  @objc func connect(_ call: CAPPluginCall) {
19
21
  if localServer == nil {
20
22
  localServer = HttpLocalServerSwifter(delegate: self)
21
23
  }
24
+
22
25
  localServer?.connect(call)
23
26
  }
24
27
 
@@ -32,6 +35,7 @@ public class HttpLocalServerSwifterPlugin: CAPPlugin, CAPBridgedPlugin, HttpLoca
32
35
  @objc func sendResponse(_ call: CAPPluginCall) {
33
36
  guard let requestId = call.getString("requestId"), !requestId.isEmpty else {
34
37
  call.reject("Missing requestId")
38
+
35
39
  return
36
40
  }
37
41
 
@@ -41,6 +45,7 @@ public class HttpLocalServerSwifterPlugin: CAPPlugin, CAPBridgedPlugin, HttpLoca
41
45
  requestId: requestId,
42
46
  responseData: responseData
43
47
  )
48
+
44
49
  call.resolve()
45
50
  }
46
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cappitolian/http-local-server-swifter",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
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",