@sigx/lynx-websocket 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/android/com/sigx/websocket/WebSocketEventBus.kt +104 -0
- package/android/com/sigx/websocket/WebSocketModule.kt +78 -0
- package/android/com/sigx/websocket/WebSocketPublisher.kt +44 -0
- package/android/com/sigx/websocket/WebSocketTaskStore.kt +115 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/websocket.d.ts +91 -0
- package/dist/websocket.d.ts.map +1 -0
- package/dist/websocket.js +429 -0
- package/dist/websocket.js.map +1 -0
- package/ios/WebSocketEventBus.swift +86 -0
- package/ios/WebSocketModule.swift +89 -0
- package/ios/WebSocketPublisher.swift +34 -0
- package/ios/WebSocketTaskStore.swift +128 -0
- package/package.json +58 -0
- package/sigx-module.json +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Andreas Ekdahl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# @sigx/lynx-websocket
|
|
2
|
+
|
|
3
|
+
Browser-standard `WebSocket` client for sigx-lynx. Wraps
|
|
4
|
+
`URLSessionWebSocketTask` on iOS and OkHttp `WebSocket` on Android, and
|
|
5
|
+
installs a global `WebSocket` class that matches the WHATWG / MDN API so
|
|
6
|
+
portable web code works unchanged.
|
|
7
|
+
|
|
8
|
+
> Note: this is **only for WebSockets**. Plain HTTP works out of the box —
|
|
9
|
+
> Lynx already ships a global `fetch()`. See [Networking](#networking)
|
|
10
|
+
> below.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add @sigx/lynx-websocket
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// sigx.lynx.config.ts
|
|
20
|
+
export default defineLynxConfig({
|
|
21
|
+
modules: ['@sigx/lynx-websocket'],
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then run `sigx-lynx prebuild` to regenerate the iOS / Android projects.
|
|
26
|
+
No permissions are required on either platform (OkHttp piggy-backs on
|
|
27
|
+
the app's standard `INTERNET` permission, which the host app already
|
|
28
|
+
declares).
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Listing the package in `modules:` registers a global `WebSocket` — you don't
|
|
33
|
+
need to `import` anything to use it:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const ws = new WebSocket('wss://ws.postman-echo.com/raw');
|
|
37
|
+
|
|
38
|
+
ws.onopen = () => ws.send('hello');
|
|
39
|
+
ws.onmessage = (event) => {
|
|
40
|
+
console.log('received', event.data);
|
|
41
|
+
};
|
|
42
|
+
ws.onclose = (event) => {
|
|
43
|
+
console.log('closed', event.code, event.reason);
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If you prefer an explicit import (e.g. for TypeScript clarity, or to use
|
|
48
|
+
inside a library that shouldn't assume the global is set):
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { WebSocket } from '@sigx/lynx-websocket';
|
|
52
|
+
|
|
53
|
+
const ws = new WebSocket('wss://example.com/socket');
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Binary frames
|
|
57
|
+
|
|
58
|
+
`binaryType` is fixed to `'arraybuffer'` — `'blob'` is not supported (Lynx
|
|
59
|
+
doesn't ship a `Blob` polyfill, matching upstream's `fetch` constraints).
|
|
60
|
+
Send `ArrayBuffer` / typed arrays directly:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
const ws = new WebSocket('wss://example.com/socket');
|
|
64
|
+
ws.binaryType = 'arraybuffer';
|
|
65
|
+
|
|
66
|
+
ws.onmessage = (event) => {
|
|
67
|
+
if (event.data instanceof ArrayBuffer) {
|
|
68
|
+
const bytes = new Uint8Array(event.data);
|
|
69
|
+
// ...
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
ws.onopen = () => {
|
|
74
|
+
ws.send(new Uint8Array([0x01, 0x02, 0x03]));
|
|
75
|
+
};
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Subprotocols
|
|
79
|
+
|
|
80
|
+
Pass a string or array of strings as the second arg to the constructor —
|
|
81
|
+
the negotiated value is available on `ws.protocol` after `open`:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const ws = new WebSocket('wss://example.com/chat', ['v2.chat', 'v1.chat']);
|
|
85
|
+
ws.onopen = () => console.log('negotiated', ws.protocol);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
Matches [`WebSocket` on MDN](https://developer.mozilla.org/docs/Web/API/WebSocket):
|
|
91
|
+
|
|
92
|
+
| Member | Type | Notes |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `new WebSocket(url, protocols?)` | constructor | `url` must be `ws:` / `wss:` (http/https accepted, normalised by native). |
|
|
95
|
+
| `readyState` | `0 \| 1 \| 2 \| 3` | `CONNECTING / OPEN / CLOSING / CLOSED`. |
|
|
96
|
+
| `url` | `string` | The URL passed to the constructor. |
|
|
97
|
+
| `protocol` | `string` | Negotiated subprotocol; `''` until `open`. |
|
|
98
|
+
| `extensions` | `string` | Negotiated `Sec-WebSocket-Extensions`. |
|
|
99
|
+
| `bufferedAmount` | `number` | Bytes handed to native, not yet acked. Approximate; updated on `send`. |
|
|
100
|
+
| `binaryType` | `'arraybuffer'` | Fixed — `'blob'` is unsupported. |
|
|
101
|
+
| `send(data)` | method | `string \| ArrayBuffer \| ArrayBufferView`. Throws if `CONNECTING`. |
|
|
102
|
+
| `close(code?, reason?)` | method | Browser semantics: `1000` or `3000–4999`; reason ≤123 UTF-8 bytes. |
|
|
103
|
+
| `onopen / onmessage / onerror / onclose` | listener slot | Standard WHATWG event shape. |
|
|
104
|
+
| `addEventListener(type, fn)` / `removeEventListener` / `dispatchEvent` | EventTarget | Multiple listeners per event. |
|
|
105
|
+
| Class constants | `CONNECTING (0) / OPEN (1) / CLOSING (2) / CLOSED (3)` | On both class and instance. |
|
|
106
|
+
| `isWebSocketAvailable()` | export | Whether the native module is registered (useful in tests / SSR). |
|
|
107
|
+
|
|
108
|
+
## Caveats vs the browser
|
|
109
|
+
|
|
110
|
+
- **No `Blob`** binary type — use `ArrayBuffer`.
|
|
111
|
+
- **`bufferedAmount`** is a JS-side approximation (bytes handed off to
|
|
112
|
+
native), not the OS socket buffer level. Sufficient for backpressure
|
|
113
|
+
hinting; don't use it for exact accounting.
|
|
114
|
+
- **`Sec-WebSocket-Extensions`** negotiation is whatever URLSession (iOS)
|
|
115
|
+
or OkHttp (Android) offers — neither advertises `permessage-deflate` by
|
|
116
|
+
default. If you need it, configure the server to live without it or open
|
|
117
|
+
an issue.
|
|
118
|
+
- **No `Sec-WebSocket-Key` access** — handshake headers other than
|
|
119
|
+
`Sec-WebSocket-Protocol` aren't customizable from JS in this version.
|
|
120
|
+
|
|
121
|
+
## How it works
|
|
122
|
+
|
|
123
|
+
JS → native is a 3-method bridge (`create / send / close`). Native → JS is
|
|
124
|
+
a single `__sigxWebSocketEvent` global event multiplexed by a monotonic
|
|
125
|
+
numeric id; the shim demultiplexes per-instance. A per-`LynxView`
|
|
126
|
+
publisher pumps events from a process-wide `WebSocketEventBus` into
|
|
127
|
+
`LynxView.sendGlobalEvent`. Sockets outlive any single LynxView so a
|
|
128
|
+
template reload doesn't drop in-flight connections that JS is
|
|
129
|
+
re-attaching to.
|
|
130
|
+
|
|
131
|
+
## Related
|
|
132
|
+
|
|
133
|
+
- **HTTP**: just call `fetch()` — it's a built-in Lynx global. No package
|
|
134
|
+
needed. See [upstream Lynx fetch docs](https://lynxjs.org/api/lynx-api/global/fetch.html)
|
|
135
|
+
for the Lynx subset (no CORS / redirect / keepalive / FormData / Blob).
|
|
136
|
+
- **Connectivity**: [`@sigx/lynx-network`](../lynx-network) for online /
|
|
137
|
+
offline and connection-type state.
|
|
138
|
+
|
|
139
|
+
## Reference app
|
|
140
|
+
|
|
141
|
+
Once wired in, point it at an echo service to smoke-test:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const ws = new WebSocket('wss://ws.postman-echo.com/raw');
|
|
145
|
+
ws.onopen = () => ws.send('ping');
|
|
146
|
+
ws.onmessage = (e) => console.log(e.data); // → 'ping'
|
|
147
|
+
```
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
package com.sigx.websocket
|
|
2
|
+
|
|
3
|
+
import com.lynx.react.bridge.JavaOnlyMap
|
|
4
|
+
import java.util.UUID
|
|
5
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Process-wide pub/sub bus that owns the conversion of native WebSocket
|
|
9
|
+
* callbacks into JS-side event payloads. OkHttp listeners write here;
|
|
10
|
+
* per-LynxView [WebSocketPublisher] instances read.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the publisher pattern used elsewhere in the repo
|
|
13
|
+
* (`LinkingState`, `SafeAreaPublisher`, …): native lifecycle is decoupled
|
|
14
|
+
* from any specific LynxView so events survive view recreation and
|
|
15
|
+
* per-view subscribers can fan out without holding onto the WS task.
|
|
16
|
+
*
|
|
17
|
+
* Payload keys match the `NativeEvent` interface in `src/websocket.ts`.
|
|
18
|
+
*/
|
|
19
|
+
internal object WebSocketEventBus {
|
|
20
|
+
|
|
21
|
+
private val listeners = CopyOnWriteArrayList<Pair<UUID, (JavaOnlyMap) -> Unit>>()
|
|
22
|
+
|
|
23
|
+
fun addListener(fn: (JavaOnlyMap) -> Unit): UUID {
|
|
24
|
+
val token = UUID.randomUUID()
|
|
25
|
+
listeners.add(token to fn)
|
|
26
|
+
return token
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fun removeListener(token: UUID) {
|
|
30
|
+
listeners.removeAll { it.first == token }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fun publishOpen(id: Int, protocol: String, extensions: String) {
|
|
34
|
+
emit(
|
|
35
|
+
mapOf(
|
|
36
|
+
"id" to id,
|
|
37
|
+
"type" to "open",
|
|
38
|
+
"protocol" to protocol,
|
|
39
|
+
"extensions" to extensions,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fun publishMessageText(id: Int, text: String) {
|
|
45
|
+
emit(
|
|
46
|
+
mapOf(
|
|
47
|
+
"id" to id,
|
|
48
|
+
"type" to "message",
|
|
49
|
+
"data" to text,
|
|
50
|
+
"isBinary" to false,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fun publishMessageBinary(id: Int, base64: String) {
|
|
56
|
+
emit(
|
|
57
|
+
mapOf(
|
|
58
|
+
"id" to id,
|
|
59
|
+
"type" to "message",
|
|
60
|
+
"binary" to base64,
|
|
61
|
+
"isBinary" to true,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fun publishError(id: Int, message: String) {
|
|
67
|
+
emit(
|
|
68
|
+
mapOf(
|
|
69
|
+
"id" to id,
|
|
70
|
+
"type" to "error",
|
|
71
|
+
"data" to message,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fun publishClose(id: Int, code: Int, reason: String, wasClean: Boolean) {
|
|
77
|
+
emit(
|
|
78
|
+
mapOf(
|
|
79
|
+
"id" to id,
|
|
80
|
+
"type" to "close",
|
|
81
|
+
"code" to code,
|
|
82
|
+
"reason" to reason,
|
|
83
|
+
"wasClean" to wasClean,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun emit(payload: Map<String, Any>) {
|
|
89
|
+
// JavaOnlyMap doesn't ship a `from(Map)` overload that handles
|
|
90
|
+
// mixed value types on every host, so build it explicitly to keep
|
|
91
|
+
// the bridge happy.
|
|
92
|
+
val map = JavaOnlyMap()
|
|
93
|
+
for ((k, v) in payload) {
|
|
94
|
+
when (v) {
|
|
95
|
+
is String -> map.putString(k, v)
|
|
96
|
+
is Int -> map.putInt(k, v)
|
|
97
|
+
is Double -> map.putDouble(k, v)
|
|
98
|
+
is Boolean -> map.putBoolean(k, v)
|
|
99
|
+
else -> map.putString(k, v.toString())
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for ((_, fn) in listeners) fn(map)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package com.sigx.websocket
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import com.lynx.jsbridge.LynxMethod
|
|
6
|
+
import com.lynx.jsbridge.LynxModule
|
|
7
|
+
import com.lynx.react.bridge.Callback
|
|
8
|
+
import com.lynx.react.bridge.JavaOnlyMap
|
|
9
|
+
import com.lynx.react.bridge.ReadableArray
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Native WebSocket bridge — JS-callable side.
|
|
13
|
+
*
|
|
14
|
+
* JS usage (via the `@sigx/lynx-websocket` shim, not directly):
|
|
15
|
+
*
|
|
16
|
+
* NativeModules.WebSocket.create(id, url, protocols, cb)
|
|
17
|
+
* NativeModules.WebSocket.send(id, payload, isBinary, cb)
|
|
18
|
+
* NativeModules.WebSocket.close(id, code, reason, cb)
|
|
19
|
+
*
|
|
20
|
+
* Async lifecycle events (`open`, `message`, `error`, `close`) are pushed
|
|
21
|
+
* back via [WebSocketEventBus], which a per-LynxView [WebSocketPublisher]
|
|
22
|
+
* forwards to JS through `LynxView.sendGlobalEvent("__sigxWebSocketEvent",
|
|
23
|
+
* [...])`.
|
|
24
|
+
*
|
|
25
|
+
* Implemented with OkHttp's WebSocket. Each socket is stored in
|
|
26
|
+
* [WebSocketTaskStore] keyed by the JS-supplied numeric id; the same id is
|
|
27
|
+
* echoed back in every event so the JS shim can demultiplex.
|
|
28
|
+
*/
|
|
29
|
+
class WebSocketModule(context: Context) : LynxModule(context) {
|
|
30
|
+
|
|
31
|
+
@LynxMethod
|
|
32
|
+
fun create(id: Int, url: String?, protocols: ReadableArray?, callback: Callback?) {
|
|
33
|
+
if (url.isNullOrEmpty()) {
|
|
34
|
+
WebSocketEventBus.publishError(id, "Invalid URL")
|
|
35
|
+
WebSocketEventBus.publishClose(id, 1006, "Invalid URL", wasClean = false)
|
|
36
|
+
callback?.invoke(JavaOnlyMap.from(emptyMap<String, Any>()))
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
val protoList = mutableListOf<String>()
|
|
41
|
+
if (protocols != null) {
|
|
42
|
+
for (i in 0 until protocols.size()) {
|
|
43
|
+
val v = protocols.getString(i)
|
|
44
|
+
if (!v.isNullOrEmpty()) protoList.add(v)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
WebSocketTaskStore.create(id, url, protoList)
|
|
50
|
+
callback?.invoke(JavaOnlyMap.from(emptyMap<String, Any>()))
|
|
51
|
+
} catch (e: Exception) {
|
|
52
|
+
Log.e(TAG, "create($id) failed: ${e.message}")
|
|
53
|
+
WebSocketEventBus.publishError(id, e.message ?: "create failed")
|
|
54
|
+
WebSocketEventBus.publishClose(id, 1006, e.message ?: "", wasClean = false)
|
|
55
|
+
callback?.invoke(JavaOnlyMap.from(mapOf("error" to (e.message ?: "create failed"))))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@LynxMethod
|
|
60
|
+
fun send(id: Int, payload: String?, isBinary: Boolean, callback: Callback?) {
|
|
61
|
+
val ok = WebSocketTaskStore.send(id, payload ?: "", isBinary)
|
|
62
|
+
if (!ok) {
|
|
63
|
+
callback?.invoke(JavaOnlyMap.from(mapOf("error" to "WebSocket $id not found or not open")))
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
callback?.invoke(JavaOnlyMap.from(emptyMap<String, Any>()))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@LynxMethod
|
|
70
|
+
fun close(id: Int, code: Int, reason: String?, callback: Callback?) {
|
|
71
|
+
WebSocketTaskStore.close(id, code, reason ?: "")
|
|
72
|
+
callback?.invoke(JavaOnlyMap.from(emptyMap<String, Any>()))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private companion object {
|
|
76
|
+
const val TAG = "SigxWebSocketModule"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
package com.sigx.websocket
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import com.lynx.react.bridge.JavaOnlyArray
|
|
5
|
+
import com.lynx.tasm.LynxView
|
|
6
|
+
import java.util.UUID
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-[LynxView] publisher that pumps [WebSocketEventBus] payloads into JS
|
|
10
|
+
* via `LynxView.sendGlobalEvent("__sigxWebSocketEvent", [...])`.
|
|
11
|
+
*
|
|
12
|
+
* One instance per LynxView; instantiated by the generated
|
|
13
|
+
* `GeneratedLifecyclePublishers.attachAll(lynxView)` and retained for the
|
|
14
|
+
* LynxView's lifetime. The bus is global so opening a socket from one
|
|
15
|
+
* LynxView and reading it from another (via the JS shim) works, but in
|
|
16
|
+
* practice each LynxView holds its own JS heap so events are delivered to
|
|
17
|
+
* the matching view only.
|
|
18
|
+
*/
|
|
19
|
+
class WebSocketPublisher(private val lynxView: LynxView) {
|
|
20
|
+
|
|
21
|
+
private var token: UUID? = null
|
|
22
|
+
|
|
23
|
+
fun attach() {
|
|
24
|
+
token = WebSocketEventBus.addListener { payload ->
|
|
25
|
+
try {
|
|
26
|
+
val params = JavaOnlyArray()
|
|
27
|
+
params.pushMap(payload)
|
|
28
|
+
lynxView.sendGlobalEvent(EVENT_NAME, params)
|
|
29
|
+
} catch (e: Throwable) {
|
|
30
|
+
Log.w(TAG, "publish failed: ${e.message}")
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fun detach() {
|
|
36
|
+
token?.let { WebSocketEventBus.removeListener(it) }
|
|
37
|
+
token = null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private companion object {
|
|
41
|
+
const val TAG = "SigxWebSocketPublisher"
|
|
42
|
+
const val EVENT_NAME = "__sigxWebSocketEvent"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
package com.sigx.websocket
|
|
2
|
+
|
|
3
|
+
import android.util.Base64
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import okhttp3.OkHttpClient
|
|
6
|
+
import okhttp3.Request
|
|
7
|
+
import okhttp3.Response
|
|
8
|
+
import okhttp3.WebSocket
|
|
9
|
+
import okhttp3.WebSocketListener
|
|
10
|
+
import okio.ByteString
|
|
11
|
+
import okio.ByteString.Companion.toByteString
|
|
12
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
13
|
+
import java.util.concurrent.TimeUnit
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Process-wide registry of live OkHttp [WebSocket] instances keyed by the
|
|
17
|
+
* JS-supplied numeric id. Sockets outlive any single LynxView (a page
|
|
18
|
+
* reload should not drop in-flight sockets that the JS bundle is
|
|
19
|
+
* re-attaching to).
|
|
20
|
+
*/
|
|
21
|
+
internal object WebSocketTaskStore {
|
|
22
|
+
|
|
23
|
+
private const val TAG = "SigxWebSocketStore"
|
|
24
|
+
|
|
25
|
+
private val sockets = ConcurrentHashMap<Int, WebSocket>()
|
|
26
|
+
|
|
27
|
+
private val client: OkHttpClient by lazy {
|
|
28
|
+
OkHttpClient.Builder()
|
|
29
|
+
// No read timeout on established sockets — close frames drive teardown.
|
|
30
|
+
.readTimeout(0, TimeUnit.MILLISECONDS)
|
|
31
|
+
.pingInterval(0, TimeUnit.MILLISECONDS)
|
|
32
|
+
.build()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fun create(id: Int, url: String, protocols: List<String>) {
|
|
36
|
+
// If JS re-uses an id (shouldn't — ids are monotonic), tear down
|
|
37
|
+
// the prior socket first to avoid leaks.
|
|
38
|
+
sockets.remove(id)?.cancel()
|
|
39
|
+
|
|
40
|
+
val builder = Request.Builder().url(url)
|
|
41
|
+
if (protocols.isNotEmpty()) {
|
|
42
|
+
builder.addHeader("Sec-WebSocket-Protocol", protocols.joinToString(", "))
|
|
43
|
+
}
|
|
44
|
+
val request = builder.build()
|
|
45
|
+
val ws = client.newWebSocket(request, Listener(id))
|
|
46
|
+
sockets[id] = ws
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fun send(id: Int, payload: String, isBinary: Boolean): Boolean {
|
|
50
|
+
val ws = sockets[id] ?: return false
|
|
51
|
+
return if (isBinary) {
|
|
52
|
+
// Payload is base64-encoded on the JS side — decode to raw bytes.
|
|
53
|
+
val bytes = try {
|
|
54
|
+
Base64.decode(payload, Base64.DEFAULT)
|
|
55
|
+
} catch (e: IllegalArgumentException) {
|
|
56
|
+
Log.w(TAG, "send($id) invalid base64: ${e.message}")
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
ws.send(bytes.toByteString())
|
|
60
|
+
} else {
|
|
61
|
+
ws.send(payload)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun close(id: Int, code: Int, reason: String) {
|
|
66
|
+
val ws = sockets[id] ?: return
|
|
67
|
+
// OkHttp returns false if the socket is already closing/closed —
|
|
68
|
+
// either way the listener will fire and we'll clean up.
|
|
69
|
+
ws.close(code, reason)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private fun forget(id: Int) {
|
|
73
|
+
sockets.remove(id)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* OkHttp WebSocketListener — converts OkHttp callbacks into
|
|
78
|
+
* [WebSocketEventBus] publishes. One listener per socket so we can
|
|
79
|
+
* capture the id in the closure.
|
|
80
|
+
*/
|
|
81
|
+
private class Listener(private val id: Int) : WebSocketListener() {
|
|
82
|
+
|
|
83
|
+
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
84
|
+
val protocol = response.header("Sec-WebSocket-Protocol") ?: ""
|
|
85
|
+
val extensions = response.header("Sec-WebSocket-Extensions") ?: ""
|
|
86
|
+
WebSocketEventBus.publishOpen(id, protocol, extensions)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
90
|
+
WebSocketEventBus.publishMessageText(id, text)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
|
94
|
+
val b64 = Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)
|
|
95
|
+
WebSocketEventBus.publishMessageBinary(id, b64)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
|
99
|
+
// Echo the peer's close back — OkHttp does the responding close
|
|
100
|
+
// for us automatically here.
|
|
101
|
+
webSocket.close(code, reason)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
105
|
+
forget(id)
|
|
106
|
+
WebSocketEventBus.publishClose(id, code, reason, wasClean = true)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
110
|
+
forget(id)
|
|
111
|
+
WebSocketEventBus.publishError(id, t.message ?: t.javaClass.simpleName)
|
|
112
|
+
WebSocketEventBus.publishClose(id, 1006, t.message ?: "", wasClean = false)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@sigx/lynx-websocket` — browser-standard `WebSocket` for sigx-lynx.
|
|
3
|
+
*
|
|
4
|
+
* Importing this module (or listing `@sigx/lynx-websocket` in
|
|
5
|
+
* `sigx.lynx.config.ts → modules:`) installs a global `WebSocket` class on
|
|
6
|
+
* `globalThis`, so portable web code that does `new WebSocket(url)` works
|
|
7
|
+
* unchanged.
|
|
8
|
+
*/
|
|
9
|
+
import { WebSocket as SigxWebSocket } from './websocket.js';
|
|
10
|
+
export { WebSocket, isWebSocketAvailable } from './websocket.js';
|
|
11
|
+
// Side-effect: register on the global so consumers don't need an import
|
|
12
|
+
// site to call `new WebSocket(...)`. Mirrors the CLI plugin's auto-import
|
|
13
|
+
// behavior for native modules — listing the package in `modules: [...]`
|
|
14
|
+
// already causes this file to run at bundle init time.
|
|
15
|
+
{
|
|
16
|
+
const g = globalThis;
|
|
17
|
+
if (typeof g.WebSocket === 'undefined') {
|
|
18
|
+
g.WebSocket = SigxWebSocket;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,SAAS,IAAI,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE5D,OAAO,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAEjE,wEAAwE;AACxE,0EAA0E;AAC1E,wEAAwE;AACxE,uDAAuD;AACvD,CAAC;IACG,MAAM,CAAC,GAAG,UAAgD,CAAC;IAC3D,IAAI,OAAO,CAAC,CAAC,SAAS,KAAK,WAAW,EAAE,CAAC;QACrC,CAAC,CAAC,SAAS,GAAG,aAAa,CAAC;IAChC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Bridge to lynx's `GlobalEventEmitter` for native → JS events. */
|
|
2
|
+
interface GlobalEventEmitterLike {
|
|
3
|
+
addListener: (name: string, fn: (...a: unknown[]) => void) => void;
|
|
4
|
+
removeListener: (name: string, fn: (...a: unknown[]) => void) => void;
|
|
5
|
+
}
|
|
6
|
+
/** Wire payload pushed by the native side. */
|
|
7
|
+
interface NativeEvent {
|
|
8
|
+
id: number;
|
|
9
|
+
type: 'open' | 'message' | 'error' | 'close';
|
|
10
|
+
/** Text body for message events, error message for error events. */
|
|
11
|
+
data?: string;
|
|
12
|
+
/** Base64-encoded binary payload (set when isBinary === true). */
|
|
13
|
+
binary?: string;
|
|
14
|
+
isBinary?: boolean;
|
|
15
|
+
/** Negotiated subprotocol — populated on the open event. */
|
|
16
|
+
protocol?: string;
|
|
17
|
+
/** Negotiated extensions — populated on the open event. */
|
|
18
|
+
extensions?: string;
|
|
19
|
+
/** Close frame fields. */
|
|
20
|
+
code?: number;
|
|
21
|
+
reason?: string;
|
|
22
|
+
wasClean?: boolean;
|
|
23
|
+
}
|
|
24
|
+
type ReadyState = 0 | 1 | 2 | 3;
|
|
25
|
+
type BinaryType = 'arraybuffer';
|
|
26
|
+
type EventListenerLike = ((ev: WebSocketEventLike) => void) | {
|
|
27
|
+
handleEvent(ev: WebSocketEventLike): void;
|
|
28
|
+
};
|
|
29
|
+
/** Minimal WHATWG `Event` shape — enough for portable WS code. */
|
|
30
|
+
interface WebSocketEventLike {
|
|
31
|
+
type: string;
|
|
32
|
+
target: WebSocket;
|
|
33
|
+
currentTarget: WebSocket;
|
|
34
|
+
data?: unknown;
|
|
35
|
+
code?: number;
|
|
36
|
+
reason?: string;
|
|
37
|
+
wasClean?: boolean;
|
|
38
|
+
message?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* WHATWG-compatible WebSocket. Drop-in for browser code.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* const ws = new WebSocket('wss://ws.postman-echo.com/raw');
|
|
46
|
+
* ws.onopen = () => ws.send('hello');
|
|
47
|
+
* ws.onmessage = e => console.log(e.data);
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export declare class WebSocket {
|
|
51
|
+
static readonly CONNECTING: 0;
|
|
52
|
+
static readonly OPEN: 1;
|
|
53
|
+
static readonly CLOSING: 2;
|
|
54
|
+
static readonly CLOSED: 3;
|
|
55
|
+
readonly CONNECTING: 0;
|
|
56
|
+
readonly OPEN: 1;
|
|
57
|
+
readonly CLOSING: 2;
|
|
58
|
+
readonly CLOSED: 3;
|
|
59
|
+
readonly url: string;
|
|
60
|
+
protocol: string;
|
|
61
|
+
extensions: string;
|
|
62
|
+
bufferedAmount: number;
|
|
63
|
+
binaryType: BinaryType;
|
|
64
|
+
onopen: ((ev: WebSocketEventLike) => void) | null;
|
|
65
|
+
onmessage: ((ev: WebSocketEventLike) => void) | null;
|
|
66
|
+
onerror: ((ev: WebSocketEventLike) => void) | null;
|
|
67
|
+
onclose: ((ev: WebSocketEventLike) => void) | null;
|
|
68
|
+
private _readyState;
|
|
69
|
+
private readonly _id;
|
|
70
|
+
private readonly _listeners;
|
|
71
|
+
get readyState(): ReadyState;
|
|
72
|
+
constructor(url: string, protocols?: string | string[]);
|
|
73
|
+
send(data: string | ArrayBuffer | ArrayBufferView): void;
|
|
74
|
+
close(code?: number, reason?: string): void;
|
|
75
|
+
addEventListener(type: string, listener: EventListenerLike): void;
|
|
76
|
+
removeEventListener(type: string, listener: EventListenerLike): void;
|
|
77
|
+
dispatchEvent(event: WebSocketEventLike): boolean;
|
|
78
|
+
/** @internal — called by the shared global-event subscriber. */
|
|
79
|
+
private _dispatch;
|
|
80
|
+
private _invoke;
|
|
81
|
+
}
|
|
82
|
+
/** Whether the native WebSocket module is registered in this build. */
|
|
83
|
+
export declare function isWebSocketAvailable(): boolean;
|
|
84
|
+
/** @internal */
|
|
85
|
+
export declare const __internal: {
|
|
86
|
+
deliver(evt: NativeEvent): void;
|
|
87
|
+
reset(): void;
|
|
88
|
+
readonly cachedEmitter: GlobalEventEmitterLike | null;
|
|
89
|
+
};
|
|
90
|
+
export {};
|
|
91
|
+
//# sourceMappingURL=websocket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"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"}
|