Skip to content

feat: ✨ Native Websocket Plugin (uses okhttp) #1335

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"image": "mcr.microsoft.com/devcontainers/universal:2",
"features": {
"ghcr.io/nordcominc/devcontainer-features/android-sdk:1": {}
}
}
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"cordova-plugin-advanced-http": {
"ANDROIDBLACKLISTSECURESOCKETPROTOCOLS": "SSLv3,TLSv1"
},
"cordova-plugin-websocket": {},
"com.foxdebug.acode.exec": {}
},
"platforms": [
Expand Down Expand Up @@ -76,6 +77,7 @@
"cordova-plugin-server": "file:src/plugins/server",
"cordova-plugin-sftp": "file:src/plugins/sftp",
"cordova-plugin-system": "file:src/plugins/system",
"cordova-plugin-websocket": "file:src/plugins/websocket",
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.0",
"path-browserify": "^1.0.1",
Expand Down
101 changes: 101 additions & 0 deletions src/plugins/websocket/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Cordova Plugin: OkHttp WebSocket

A Cordova plugin that uses [OkHttp](https://square.github.io/okhttp/) to provide WebSocket support in your Cordova app.
It aims to mimic the [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) in JavaScript, with additional features.

## Features

* ✅ WebSocket API-like interface
* ✅ Event support: `onopen`, `onmessage`, `onerror`, `onclose`
* ✅ `extensions` and `readyState` properties
* ✅ `listClients()` to list active connections
* ✅ Support for protocols
* ✅ Support for Custom Headers.
* ✅ Compatible with Cordova for Android

---

## Usage

### Import

```javascript
const WebSocketPlugin = cordova.websocket;
```

### Connect to WebSocket

```javascript
WebSocketPlugin.connect("wss://example.com/socket", ["protocol1", "protocol2"], headers)
.then(ws => {
ws.onopen = (e) => console.log("Connected!", e);
ws.onmessage = (e) => console.log("Message:", e.data);
ws.onerror = (e) => console.error("Error:", e);
ws.onclose = (e) => console.log("Closed:", e);

ws.send("Hello from Cordova!");
ws.close();
})
.catch(err => console.error("WebSocket connection failed:", err));
```

---

## API Reference

### Methods

* `WebSocketPlugin.connect(url, protocols, headers)`

* Connects to a WebSocket server.
* `url`: The WebSocket server URL.
* `protocols`: (Optional) An array of subprotocol strings.
* `headers` (object, optional): Custom headers as key-value pairs.
* **Returns:** A Promise that resolves to a `WebSocketInstance`.
* `WebSocketPlugin.listClients()`
* Lists all stored webSocket instance IDs.
* **Returns:** `Promise`that resolves to an array of `instanceId` strings.

* `WebSocketPlugin.send(instanceId, message)`
* same as `WebSocketInstance.send(message)` but needs `instanceId`.
* **Returns:** `Promise` that resolves.

* `WebSocketPlugin.close(instanceId, code, reason)`
* same as `WebSocketInstance.close(code, reason)` but needs `instanceId`.
* **Returns:** `Promise` that resolves.

* `WebSocketInstance.send(message)`

* Sends a message to the server.
* Throws an error if the connection is not open.

* `WebSocketInstance.close(code, reason)`

* Closes the connection.
* `code`: (Optional) If unspecified, a close code for the connection is automatically set: to 1000 for a normal closure, or otherwise to [another standard value in the range 1001-1015](https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1) that indicates the actual reason the connection was closed.
* `reason`: A string providing a [custom WebSocket connection close reason](https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.6) (a concise human-readable prose explanation for the closure). The value must be no longer than 123 bytes (encoded in UTF-8).

---

### Properties of `WebSocketInstance`

* `onopen`: Event listener for connection open.
* `onmessage`: Event listener for messages received.
* `onclose`: Event listener for connection close.
* `onerror`: Event listener for errors.
* `readyState`: (number) The state of the connection.

* 0 (`CONNECTING`): Socket created, not yet open.
* 1 (`OPEN`): Connection is open and ready.
* 2 (`CLOSING`): Connection is closing.
* 3 (`CLOSED`): Connection is closed or couldn't be opened.
* `extensions`: (string) Extensions negotiated by the server (usually empty or a list).

---

## Notes

* Only supported on Android (via OkHttp).
* Make sure to handle connection lifecycle properly (close sockets when done).
* `listClients()` is useful for debugging and management.
---
22 changes: 22 additions & 0 deletions src/plugins/websocket/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "cordova-plugin-websocket",
"version": "0.0.1",
"description": "This cordova plugin is created to use WebSocket (client) in web/js.",
"cordova": {
"id": "cordova-plugin-websocket",
"platforms": [
"android"
]
},
"keywords": [
"cordova",
"websocket",
"cordova-android",
"ws"
],
"author": "Acode-Foundation (created by UnschooledGamer)",
"license": "Apache-2.0",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}
}
21 changes: 21 additions & 0 deletions src/plugins/websocket/plugin.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<plugin id="cordova-plugin-websocket" version="0.0.1" xmlns="http://apache.org/cordova/ns/plugins/1.0">
<name>cordova-plugin-websocket</name>
<description>Cordova Websocket</description>
<license>MIT</license>
Copy link
Preview

Copilot AI May 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The license in plugin.xml is set to MIT, but package.json specifies Apache-2.0. Please align the license entries to avoid confusion for consumers.

Copilot uses AI. Check for mistakes.

<keywords>cordova,ws,WebSocket</keywords>
<js-module src="www/websocket.js" name="WebSocket">
<clobbers target="cordova.websocket" />
</js-module>

<platform name="android">
<config-file target="res/xml/config.xml" parent="/*">
<feature name="WebSocketPlugin">
<param name="android-package" value="com.foxdebug.websocket.WebSocketPlugin" />
</feature>
</config-file>
<source-file src="src/android/WebSocketPlugin.java" target-dir="src/com/foxdebug/websocket" />
<source-file src="src/android/WebSocketInstance.java" target-dir="src/com/foxdebug/websocket" />
<framework src="com.squareup.okhttp3:okhttp:4.12.0" />
</platform>
</plugin>
194 changes: 194 additions & 0 deletions src/plugins/websocket/src/android/WebSocketInstance.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package com.foxdebug.websocket;

import android.util.Log;

import androidx.annotation.NonNull;

import org.apache.cordova.*;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.util.Iterator;
import java.util.concurrent.TimeUnit;

import okhttp3.*;

import okio.ByteString;

public class WebSocketInstance extends WebSocketListener {
private static final int DEFAULT_CLOSE_CODE = 1000;
private static final String DEFAULT_CLOSE_REASON = "Normal closure";

private WebSocket webSocket;
private CallbackContext callbackContext;
private final CordovaInterface cordova;
private final String instanceId;
private String extensions = "";
private String protocol = "";
private String binaryType = "";
private int readyState = 0; // CONNECTING

// okHttpMainClient parameter is used. To have a single main client(singleton), with per-websocket configuration using newBuilder method.
public WebSocketInstance(String url, JSONArray protocols, JSONObject headers, String binaryType, OkHttpClient okHttpMainClient, CordovaInterface cordova, String instanceId) {
this.cordova = cordova;
this.instanceId = instanceId;
this.binaryType = binaryType;

OkHttpClient client = okHttpMainClient.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.build();

Request.Builder requestBuilder = new Request.Builder().url(url);

// custom headers support.
if (headers != null) {
Iterator<String> keys = headers.keys();
while (keys.hasNext()) {
String key = keys.next();
String value = headers.optString(key);
requestBuilder.addHeader(key, value);
}
}

// adds Sec-WebSocket-Protocol header if protocols is present.
if (protocols != null) {
StringBuilder protocolHeader = new StringBuilder();
for (int i = 0; i < protocols.length(); i++) {
protocolHeader.append(protocols.optString(i)).append(",");
}
if (protocolHeader.length() > 0) {
protocolHeader.setLength(protocolHeader.length() - 1);
requestBuilder.addHeader("Sec-WebSocket-Protocol", protocolHeader.toString());
}
}

client.newWebSocket(requestBuilder.build(), this);
}

public void setCallback(CallbackContext callbackContext) {
this.callbackContext = callbackContext;
PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT);
result.setKeepCallback(true);
callbackContext.sendPluginResult(result);
}

public void send(String message) {
if (this.webSocket != null) {
this.webSocket.send(message);
Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received send() action call, sending message=" + message);
} else {
Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received send() action call, ignoring... as webSocket is null (not present)");
}
}

public String close(int code, String reason) {
if (this.webSocket != null) {
this.readyState = 2; // CLOSING
try {
boolean result = this.webSocket.close(code, reason);
Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call");

// if a graceful shutdown was already underway...
// or if the web socket is already closed or canceled. do nothing.
if(!result) {
return null;
}
} catch (Exception e) {
return e.getMessage();
}

return null;
} else {
Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call, ignoring... as webSocket is null (not present)");
// TODO: finding a better way of telling it wasn't successful.
return "";
}
}

public String close() {
Log.d("WebSocketInstance", "WebSocket instanceId=" + this.instanceId + " close() called with no arguments. Using defaults.");
// Calls the more specific version with default values
return close(DEFAULT_CLOSE_CODE, DEFAULT_CLOSE_REASON);
}

@Override
public void onOpen(@NonNull WebSocket webSocket, Response response) {
this.webSocket = webSocket;
this.readyState = 1; // OPEN
this.extensions = response.headers("Sec-WebSocket-Extensions").toString();
this.protocol = response.header("Sec-WebSocket-Protocol");
Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Opened" + "received extensions=" + this.extensions);
sendEvent("open", null, false);
}

@Override
public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) {
Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Received message: " + text);
sendEvent("message", text, false);
}

// This is called when the Websocket server sends a binary(type 0x2) message.
@Override
public void onMessage(@NonNull WebSocket webSocket, @NonNull ByteString bytes) {
Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Received message(bytes): " + bytes.toString());

try {
if ("arraybuffer".equals(this.binaryType)) {
String base64 = bytes.base64();
sendEvent("message", base64, true);
} else {
sendEvent("message", bytes.utf8(), true);
}
} catch (Exception e) {
Log.e("WebSocketInstance", "Error sending message", e);
}

}

@Override
public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String reason) {
this.readyState = 2; // CLOSING
Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " is Closing code: " + code + " reason: " + reason);
}

@Override
public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String reason) {
this.readyState = 3; // CLOSED
Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Closed code: " + code + " reason: " + reason);
JSONObject closedEvent = new JSONObject();
try {
closedEvent.put("code", code);
closedEvent.put("reason", reason);
} catch (JSONException e) {
Log.e("WebSocketInstance", "Error creating close event", e);
}
sendEvent("close", closedEvent.toString(), false);
}

@Override
public void onFailure(@NonNull WebSocket webSocket, Throwable t, Response response) {
this.readyState = 3; // CLOSED
sendEvent("error", t.getMessage(), false);
Log.e("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Error: " + t.getMessage());
}

private void sendEvent(String type, String data, boolean isBinary) {
if (callbackContext != null) {
try {
JSONObject event = new JSONObject();
event.put("type", type);
event.put("extensions", this.extensions);
event.put("readyState", this.readyState);
event.put("isBinary", isBinary ? true : false);
if (data != null) event.put("data", data);
Log.d("WebSocketInstance", "sending event: " + type + " eventObj " + event.toString());
PluginResult result = new PluginResult(PluginResult.Status.OK, event);
result.setKeepCallback(true);
callbackContext.sendPluginResult(result);
} catch (Exception e) {
Log.e("WebSocketInstance", "Error sending event", e);
}
}
}
}
Loading