Skip to content

Commit f599964

Browse files
authored
Add sendBinary() to WebSocketClient (#162)
* feat: add sendBinaryDataFor to WebSocketClient for binary frame support Adds sendBinaryDataFor/sendBinary API across iOS (Starscream write(data:)), Android (OkHttp ByteString), and TypeScript layers. Data is passed as base64-encoded string over the bridge and decoded natively before sending. Needed by mattermost-mobile calls WebSocket to send binary SDP frames (msgpack-encoded) over a URLSession-backed connection, enabling TLS 1.3 support on iOS (SocketRocket/CFStream is capped at TLS 1.2). * test: add tests for sendBinary WebSocketClient feature - TypeScript: WebSocketClient.test.ts verifies sendBinary() delegates to NativeWebSocketClient.sendBinaryDataFor() with the correct url and data. Adds transformIgnorePatterns to jest config for validator ESM package. - Kotlin: WebSocketClientModuleImplTest covers the invalid-URL error path (URISyntaxException → promise.reject). Happy-path binary send requires android.util.Base64 and a live WebSocket, which are only available in instrumented (on-device) tests. * fix linter errors
1 parent a9750bf commit f599964

File tree

11 files changed

+182
-1
lines changed

11 files changed

+182
-1
lines changed

android/src/main/java/com/mattermost/networkclient/WebSocketClientModuleImpl.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.facebook.react.bridge.ReadableMap
66
import okhttp3.HttpUrl
77
import okhttp3.HttpUrl.Companion.toHttpUrl
88
import java.net.URI
9+
import java.net.URISyntaxException
910

1011
class WebSocketClientModuleImpl(reactApplicationContext: ReactApplicationContext) {
1112
private val clients = mutableMapOf<URI, NetworkClient>()
@@ -138,6 +139,23 @@ class WebSocketClientModuleImpl(reactApplicationContext: ReactApplicationContext
138139
}
139140
}
140141

142+
fun sendBinaryDataFor(wsUrl: String, data: String, promise: Promise) {
143+
val wsUri: URI
144+
try {
145+
wsUri = URI(wsUrl)
146+
} catch (error: URISyntaxException) {
147+
return promise.reject(error)
148+
}
149+
150+
try {
151+
val bytes = android.util.Base64.decode(data, android.util.Base64.DEFAULT)
152+
clients[wsUri]!!.webSocket!!.send(okio.ByteString.of(*bytes))
153+
promise.resolve(null)
154+
} catch (error: Exception) {
155+
promise.reject(error)
156+
}
157+
}
158+
141159
/**
142160
* Creates an HttpUrl from a URI by replacing the scheme with `http` or `https`
143161
*

android/src/newarch/java/com/WebSocketClientModule.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,12 @@ internal class WebSocketClientModule(reactContext: ReactApplicationContext) : Na
7171
implementation.invalidateClientFor(url, promise)
7272
}
7373

74+
override fun sendBinaryDataFor(url: String?, data: String?, promise: Promise?) {
75+
if (url.isNullOrEmpty() || data.isNullOrEmpty() || promise == null) {
76+
promise?.reject(Exception("missing parameter to send binary data"))
77+
return
78+
}
79+
implementation.sendBinaryDataFor(url, data, promise)
80+
}
81+
7482
}

android/src/oldarch/java/com/WebSocketClientModule.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ internal class WebSocketClientModule(reactContext: ReactApplicationContext) : Re
4646
implementation.sendDataFor(wsUrl, data, promise)
4747
}
4848

49+
@ReactMethod
50+
fun sendBinaryDataFor(wsUrl: String, data: String, promise: Promise) {
51+
implementation.sendBinaryDataFor(wsUrl, data, promise)
52+
}
53+
4954
@ReactMethod
5055
fun addListener(eventName: String) {
5156
// Keep: Required for RN built in Event Emitter Calls
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.mattermost.networkclient
2+
3+
import com.facebook.react.bridge.Promise
4+
import com.facebook.react.bridge.ReactApplicationContext
5+
import org.junit.Before
6+
import org.junit.Test
7+
import org.mockito.ArgumentCaptor
8+
import org.mockito.Mockito.mock
9+
import org.mockito.Mockito.verify
10+
import java.net.URISyntaxException
11+
12+
class WebSocketClientModuleImplTest {
13+
private lateinit var impl: WebSocketClientModuleImpl
14+
private lateinit var promise: Promise
15+
16+
@Before
17+
fun setUp() {
18+
val mockContext = mock(ReactApplicationContext::class.java)
19+
impl = WebSocketClientModuleImpl(mockContext)
20+
promise = mock(Promise::class.java)
21+
}
22+
23+
// Happy-path binary sends require a live WebSocket connection and android.util.Base64,
24+
// which are only available in instrumented (on-device) tests. The base64 encoding and
25+
// binary frame delivery are covered end-to-end by the mattermost-mobile test suite.
26+
27+
@Test
28+
fun `sendBinaryDataFor rejects promise for malformed URL`() {
29+
impl.sendBinaryDataFor("not a valid uri", "dGVzdA==", promise)
30+
31+
val captor = ArgumentCaptor.forClass(Throwable::class.java)
32+
verify(promise).reject(captor.capture())
33+
assert(captor.value is URISyntaxException)
34+
}
35+
}

ios/WebSocket/WebSocketClientImpl.mm

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ -(void)stopObserving {
6767
[wrapper sendDataForUrlString:url data:data resolve:resolve reject:reject];
6868
}
6969

70+
RCT_EXPORT_METHOD(sendBinaryDataFor:(NSString *)url withData:(NSString *)data withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) {
71+
[wrapper sendBinaryDataForUrlString:url data:data resolve:resolve reject:reject];
72+
}
73+
7074
RCT_EXPORT_METHOD(invalidateClientFor:(NSString *)url withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) {
7175
[wrapper invalidateClientForUrlString:url resolve:resolve reject:reject];
7276
}
@@ -164,8 +168,12 @@ - (void)invalidateClientFor:(NSString *)url resolve:(RCTPromiseResolveBlock)reso
164168
[wrapper invalidateClientForUrlString:url resolve:resolve reject:reject];
165169
}
166170

167-
- (void)sendDataFor:(NSString *)url data:(NSString *)data resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
171+
- (void)sendDataFor:(NSString *)url data:(NSString *)data resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
168172
[wrapper sendDataForUrlString:url data:data resolve:resolve reject:reject];
169173
}
170174

175+
- (void)sendBinaryDataFor:(NSString *)url data:(NSString *)data resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
176+
[wrapper sendBinaryDataForUrlString:url data:data resolve:resolve reject:reject];
177+
}
178+
171179
@end

ios/WebSocket/WebSocketWrapper.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,28 @@ var READY_STATE = [
107107
resolve(webSocket.write(string: data))
108108
}
109109

110+
@objc public func sendBinaryDataFor(urlString: String, data: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void {
111+
guard let url = URL(string: urlString) else {
112+
rejectMalformed(url: urlString, withRejecter: reject)
113+
return
114+
}
115+
116+
guard let webSocket = WebSocketManager.default.getWebSocket(for: url) else {
117+
rejectInvalidWebSocket(for: url, withRejecter: reject)
118+
return
119+
}
120+
121+
guard let binaryData = Data(base64Encoded: data) else {
122+
let err = NSError(domain: NSCocoaErrorDomain, code: NSCoderValueNotFoundError,
123+
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 data"])
124+
reject("\(err.code)", "Invalid base64 data", err)
125+
return
126+
}
127+
128+
webSocket.write(data: binaryData)
129+
resolve(nil)
130+
}
131+
110132
@objc public func invalidateClientFor(urlString: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void {
111133
guard let url = URL(string: urlString) else {
112134
rejectMalformed(url: urlString, withRejecter: reject)

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@
9494
"modulePathIgnorePatterns": [
9595
"<rootDir>/example/node_modules",
9696
"<rootDir>/lib/"
97+
],
98+
"transformIgnorePatterns": [
99+
"node_modules/(?!(@react-native|react-native|validator)/)"
97100
]
98101
},
99102
"commitlint": {

src/WebSocketClient/NativeWebSocketClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface Spec extends TurboModule {
4848
connectFor: (url: string) => Promise<void>;
4949
disconnectFor(url: string): Promise<void>;
5050
sendDataFor(url: string, data: string): Promise<void>;
51+
sendBinaryDataFor(url: string, data: string): Promise<void>;
5152
invalidateClientFor(url: string): Promise<void>;
5253
}
5354

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
import { getOrCreateWebSocketClient } from "../index";
5+
import NativeWebSocketClient from "../NativeWebSocketClient";
6+
7+
const mockNativeClient = NativeWebSocketClient as jest.Mocked<
8+
typeof NativeWebSocketClient
9+
>;
10+
11+
jest.mock("../NativeWebSocketClient", () => ({
12+
__esModule: true,
13+
default: {
14+
addListener: jest.fn(),
15+
removeListeners: jest.fn(),
16+
ensureClientFor: jest.fn().mockResolvedValue(undefined),
17+
connectFor: jest.fn().mockResolvedValue(undefined),
18+
disconnectFor: jest.fn().mockResolvedValue(undefined),
19+
sendDataFor: jest.fn().mockResolvedValue(undefined),
20+
sendBinaryDataFor: jest.fn().mockResolvedValue(undefined),
21+
invalidateClientFor: jest.fn().mockResolvedValue(undefined),
22+
},
23+
WebSocketReadyState: { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 },
24+
WebSocketEvents: {
25+
OPEN_EVENT: "WebSocketClient-Open",
26+
CLOSE_EVENT: "WebSocketClient-Close",
27+
ERROR_EVENT: "WebSocketClient-Error",
28+
MESSAGE_EVENT: "WebSocketClient-Message",
29+
READY_STATE_EVENT: "WebSocketClient-ReadyState",
30+
},
31+
}));
32+
33+
// Each test uses a unique URL to avoid the module-level CLIENTS singleton state.
34+
let urlCounter = 0;
35+
const nextUrl = () =>
36+
`ws://mattermost.example.com/api/v4/websocket?t=${++urlCounter}`;
37+
38+
describe("WebSocketClient", () => {
39+
beforeEach(() => {
40+
jest.clearAllMocks();
41+
});
42+
43+
it("should call sendDataFor when send() is invoked", async () => {
44+
const url = nextUrl();
45+
const { client } = await getOrCreateWebSocketClient(url);
46+
47+
client.send("hello");
48+
49+
expect(mockNativeClient.sendDataFor).toHaveBeenCalledWith(url, "hello");
50+
});
51+
52+
it("should call sendBinaryDataFor when sendBinary() is invoked", async () => {
53+
const url = nextUrl();
54+
const { client } = await getOrCreateWebSocketClient(url);
55+
const base64Data = "SGVsbG8gV29ybGQ=";
56+
57+
client.sendBinary(base64Data);
58+
59+
expect(mockNativeClient.sendBinaryDataFor).toHaveBeenCalledWith(
60+
url,
61+
base64Data,
62+
);
63+
});
64+
65+
it("should pass the exact url and data to sendBinaryDataFor", async () => {
66+
const url = nextUrl();
67+
const { client } = await getOrCreateWebSocketClient(url);
68+
const encoded = "dGVzdA==";
69+
70+
client.sendBinary(encoded);
71+
72+
expect(mockNativeClient.sendBinaryDataFor).toHaveBeenCalledTimes(1);
73+
expect(mockNativeClient.sendBinaryDataFor).toHaveBeenCalledWith(
74+
url,
75+
encoded,
76+
);
77+
});
78+
});

src/WebSocketClient/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class WebSocketClient implements WebSocketClientInterface {
5454
return NativeWebSocketClient.disconnectFor(this.url);
5555
};
5656
send = (data: string) => NativeWebSocketClient.sendDataFor(this.url, data);
57+
sendBinary = (data: string) =>
58+
NativeWebSocketClient.sendBinaryDataFor(this.url, data);
5759

5860
onOpen = (callback: WebSocketEventHandler) => {
5961
if (this.onWebSocketOpenSubscription) {

0 commit comments

Comments
 (0)