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

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.
@@ -17,7 +17,7 @@ import org.json.JSONException;
17
17
  import org.json.JSONObject;
18
18
 
19
19
  import java.io.IOException;
20
- import java.util.HashMap;
20
+ import java.util.HashMap;>
21
21
  import java.util.Map;
22
22
  import java.util.UUID;
23
23
  import java.util.concurrent.CompletableFuture;
@@ -28,67 +28,104 @@ import java.util.concurrent.TimeoutException;
28
28
  import fi.iki.elonen.NanoHTTPD;
29
29
 
30
30
  public class HttpLocalServerSwifter {
31
+ // Private static final properties
31
32
  private static final String TAG = "HttpLocalServerSwifter";
32
33
  private static final int DEFAULT_PORT = 8080;
33
34
  private static final int DEFAULT_TIMEOUT_SECONDS = 5;
34
35
  private static final String FALLBACK_IP = "127.0.0.1";
35
-
36
- private LocalNanoServer server;
36
+ // Changed to String to transport the full JSON response from JS
37
+ private static final ConcurrentHashMap<String, CompletableFuture<String>> pendingResponses = new ConcurrentHashMap<>();
38
+ // Add inside LocalNanoServer
39
+ private static final ConcurrentHashMap<String, long[]> rateLimitMap = new ConcurrentHashMap<>();
40
+ private static final int RATE_LIMIT = 30; // requests
41
+ private static final long RATE_WINDOW_MS = 60_000; // per minute
42
+
43
+ // Private final properties
37
44
  private final Plugin plugin;
38
45
  private final int port;
39
46
  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
-
47
+
48
+ // Private properties
49
+ private LocalNanoServer server;
50
+
51
+ private boolean isRateLimited(String ip) {
52
+ long now = System.currentTimeMillis();
53
+
54
+ rateLimitMap.compute(ip, (key, timestamps) -> {
55
+ if (timestamps == null)
56
+ timestamps = new long[] { now, 1 };
57
+ else if (now - timestamps[0] > RATE_WINDOW_MS) {
58
+ timestamps[0] = now;
59
+ timestamps[1] = 1;
60
+ } else
61
+ timestamps[1]++;
62
+
63
+ return timestamps;
64
+ });
65
+
66
+ long[] entry = rateLimitMap.get(ip);
67
+
68
+ return entry != null && entry[1] > RATE_LIMIT;
69
+ }
70
+
44
71
  public HttpLocalServerSwifter(@NonNull Plugin plugin) {
45
72
  this(plugin, DEFAULT_PORT, DEFAULT_TIMEOUT_SECONDS);
46
73
  }
47
-
74
+
48
75
  public HttpLocalServerSwifter(@NonNull Plugin plugin, int port, int timeoutSeconds) {
49
76
  this.plugin = plugin;
50
77
  this.port = port;
51
78
  this.timeoutSeconds = timeoutSeconds;
52
79
  }
53
-
80
+
54
81
  public void connect(@NonNull PluginCall call) {
55
82
  if (server != null && server.isAlive()) {
56
83
  call.reject("Server is already running");
84
+
57
85
  return;
58
86
  }
59
-
87
+
60
88
  try {
61
89
  String localIp = getLocalIpAddress(plugin.getContext());
90
+
62
91
  server = new LocalNanoServer(localIp, port, plugin, timeoutSeconds);
63
92
  server.start();
64
-
93
+
65
94
  JSObject response = new JSObject();
95
+
66
96
  response.put("ip", localIp);
67
97
  response.put("port", port);
98
+
68
99
  call.resolve(response);
69
-
100
+
70
101
  Log.i(TAG, "Server started at " + localIp + ":" + port);
71
102
  } catch (IOException e) {
72
103
  Log.e(TAG, "Failed to start server", e);
104
+
73
105
  call.reject("Failed to start server: " + e.getMessage());
74
106
  }
75
107
  }
76
-
108
+
77
109
  public void disconnect(@Nullable PluginCall call) {
78
110
  if (server != null) {
79
111
  server.stop();
112
+
80
113
  server = null;
114
+
81
115
  pendingResponses.clear();
116
+
82
117
  Log.i(TAG, "Server stopped");
83
118
  }
84
- if (call != null) call.resolve();
119
+ if (call != null)
120
+ call.resolve();
85
121
  }
86
-
122
+
87
123
  /**
88
124
  * Completes the future with the full JS response object (body, status, headers)
89
125
  */
90
126
  public static void handleJsResponse(@NonNull String requestId, @NonNull JSObject responseData) {
91
127
  CompletableFuture<String> future = pendingResponses.remove(requestId);
128
+
92
129
  if (future != null && !future.isDone()) {
93
130
  future.complete(responseData.toString());
94
131
  Log.d(TAG, "Response object delivered to future for ID: " + requestId);
@@ -97,11 +134,19 @@ public class HttpLocalServerSwifter {
97
134
 
98
135
  private @NonNull String getLocalIpAddress(@NonNull Context context) {
99
136
  try {
100
- WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
101
- if (wifiManager == null) return FALLBACK_IP;
137
+ WifiManager wifiManager = (WifiManager) context.getApplicationContext()
138
+ .getSystemService(Context.WIFI_SERVICE);
139
+
140
+ if (wifiManager == null)
141
+ return FALLBACK_IP;
142
+
102
143
  WifiInfo wifiInfo = wifiManager.getConnectionInfo();
103
- if (wifiInfo == null) return FALLBACK_IP;
144
+
145
+ if (wifiInfo == null)
146
+ return FALLBACK_IP;
147
+
104
148
  int ipAddress = wifiInfo.getIpAddress();
149
+
105
150
  return ipAddress == 0 ? FALLBACK_IP : Formatter.formatIpAddress(ipAddress);
106
151
  } catch (Exception e) {
107
152
  return FALLBACK_IP;
@@ -111,27 +156,46 @@ public class HttpLocalServerSwifter {
111
156
  private static class LocalNanoServer extends NanoHTTPD {
112
157
  private final Plugin plugin;
113
158
  private final int timeoutSeconds;
114
-
159
+
115
160
  public LocalNanoServer(@NonNull String hostname, int port, @NonNull Plugin plugin, int timeoutSeconds) {
116
161
  super(hostname, port);
117
162
  this.plugin = plugin;
118
163
  this.timeoutSeconds = timeoutSeconds;
119
164
  }
120
-
165
+
121
166
  @Override
122
167
  public Response serve(@NonNull IHTTPSession session) {
168
+ if (Method.OPTIONS.equals(session.getMethod())) {
169
+ return createCorsResponse();
170
+ }
171
+
172
+ // Rate limiting
173
+ String clientIp = session.getHeaders().getOrDefault("http-client-ip",
174
+ session.getHeaders().getOrDefault("remote-addr", "unknown"));
175
+
176
+ if (isRateLimited(clientIp)) {
177
+ Response r = newFixedLengthResponse(
178
+ Response.Status.lookup(429), "application/json",
179
+ "{\"success\":false,\"error\":\"Too many requests\"}");
180
+
181
+ addCorsHeaders(r);
182
+
183
+ return r;
184
+ }
185
+
123
186
  // Native CORS Preflight handling for efficiency
124
187
  if (Method.OPTIONS.equals(session.getMethod())) {
125
188
  return createCorsResponse();
126
189
  }
127
-
190
+
128
191
  try {
129
192
  String method = session.getMethod().name();
130
193
  String path = session.getUri();
131
194
  String body = extractBody(session);
195
+
132
196
  Map<String, String> headers = session.getHeaders();
133
197
  Map<String, String> params = session.getParms();
134
-
198
+
135
199
  // Wait for TypeScript to process logic and provide the complex response
136
200
  String jsResponseRaw = processRequest(method, path, body, headers, params);
137
201
  return createDynamicResponse(jsResponseRaw);
@@ -141,24 +205,30 @@ public class HttpLocalServerSwifter {
141
205
  }
142
206
 
143
207
  /**
144
- * Parses the JSON from JS and builds a NanoHTTPD Response with custom status and headers
208
+ * Parses the JSON from JS and builds a NanoHTTPD Response with custom status
209
+ * and headers
145
210
  */
146
211
  private Response createDynamicResponse(String jsResponseRaw) {
147
212
  try {
148
213
  JSONObject res = new JSONObject(jsResponseRaw);
149
214
  String body = res.optString("body", "");
215
+
150
216
  int statusCode = res.optInt("status", 200);
217
+
151
218
  JSONObject customHeaders = res.optJSONObject("headers");
152
219
 
153
220
  Response.IStatus status = Response.Status.lookup(statusCode);
154
- Response response = newFixedLengthResponse(status != null ? status : Response.Status.OK, "application/json", body);
155
-
221
+ Response response = newFixedLengthResponse(status != null ? status : Response.Status.OK,
222
+ "application/json", body);
223
+
156
224
  // Add standard CORS headers
157
225
  addCorsHeaders(response);
158
226
 
159
- // Inject custom headers from TypeScript (allows overriding CORS or Content-Type)
227
+ // Inject custom headers from TypeScript (allows overriding CORS or
228
+ // Content-Type)
160
229
  if (customHeaders != null) {
161
230
  java.util.Iterator<String> keys = customHeaders.keys();
231
+
162
232
  while (keys.hasNext()) {
163
233
  String key = keys.next();
164
234
  response.addHeader(key, customHeaders.getString(key));
@@ -173,32 +243,39 @@ public class HttpLocalServerSwifter {
173
243
 
174
244
  private String extractBody(@NonNull IHTTPSession session) {
175
245
  Method method = session.getMethod();
176
- if (method != Method.POST && method != Method.PUT && method != Method.PATCH) return null;
246
+
247
+ if (method != Method.POST && method != Method.PUT && method != Method.PATCH)
248
+ return null;
249
+
177
250
  try {
178
251
  HashMap<String, String> files = new HashMap<>();
179
252
  session.parseBody(files);
180
253
  String body = files.get("postData");
254
+
181
255
  return (body == null || body.isEmpty()) ? session.getQueryParameterString() : body;
182
256
  } catch (IOException | ResponseException e) {
183
257
  return null;
184
258
  }
185
259
  }
186
-
187
- private String processRequest(String method, String path, String body, Map<String, String> headers, Map<String, String> params) {
260
+
261
+ private String processRequest(String method, String path, String body, Map<String, String> headers,
262
+ Map<String, String> params) {
188
263
  String requestId = UUID.randomUUID().toString();
189
264
  JSObject requestData = new JSObject();
190
265
  requestData.put("requestId", requestId);
191
266
  requestData.put("method", method);
192
267
  requestData.put("path", path);
193
- if (body != null) requestData.put("body", body);
194
-
268
+
269
+ if (body != null)
270
+ requestData.put("body", body);
271
+
195
272
  CompletableFuture<String> future = new CompletableFuture<>();
196
273
  pendingResponses.put(requestId, future);
197
-
274
+
198
275
  if (plugin instanceof HttpLocalServerSwifterPlugin) {
199
276
  ((HttpLocalServerSwifterPlugin) plugin).fireOnRequest(requestData);
200
277
  }
201
-
278
+
202
279
  try {
203
280
  return future.get(timeoutSeconds, TimeUnit.SECONDS);
204
281
  } catch (Exception e) {
@@ -210,14 +287,17 @@ public class HttpLocalServerSwifter {
210
287
 
211
288
  private Response createCorsResponse() {
212
289
  Response response = newFixedLengthResponse(Response.Status.NO_CONTENT, "text/plain", "");
290
+
213
291
  addCorsHeaders(response);
292
+
214
293
  return response;
215
294
  }
216
295
 
217
296
  private void addCorsHeaders(@NonNull Response response) {
218
297
  response.addHeader("Access-Control-Allow-Origin", "*");
219
298
  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");
299
+ response.addHeader("Access-Control-Allow-Headers",
300
+ "Origin, Content-Type, Accept, Authorization, X-Requested-With");
221
301
  response.addHeader("Access-Control-Max-Age", "3600");
222
302
  // Prevents TCP connection reuse. NanoHTTPD does not handle keep-alive
223
303
  // 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.30",
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",