@ermis-network/ermis-chat-sdk 1.0.9 → 2.0.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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * WASM Worker — chạy ErmisCall WASM instance trên Worker thread.
3
+ *
4
+ * Mọi WASM sync calls (sendFrame, sendAudioFrame, sendControlFrame) chạy ở đây,
5
+ * KHÔNG BAO GIỜ block Main Thread. Khi P2P connection congested (peer mạng yếu),
6
+ * Worker thread bị block nhưng UI vẫn responsive.
7
+ *
8
+ * Communication: postMessage với Transferable buffers (zero-copy).
9
+ */
10
+
11
+ // @ts-ignore — WASM module import sẽ được resolve bởi bundler
12
+ import { initSync, ErmisCall } from './wasm/ermis_call_node_wasm';
13
+
14
+ let ermisCall: ErmisCall | null = null;
15
+ let isRecvActive = false;
16
+
17
+ /** Request types từ Main Thread */
18
+ type WorkerRequest =
19
+ | { id: number; type: 'init'; wasmBytes: ArrayBuffer }
20
+ | { id: number; type: 'spawn'; relayUrls: string[] }
21
+ | { id: number; type: 'getLocalEndpointAddr' }
22
+ | { id: number; type: 'connect'; address: string }
23
+ | { id: number; type: 'acceptConnection' }
24
+ | { id: number; type: 'sendFrame'; data: Uint8Array }
25
+ | { id: number; type: 'beginWithGop'; data: Uint8Array }
26
+ | { id: number; type: 'sendAudioFrame'; data: Uint8Array }
27
+ | { id: number; type: 'sendControlFrame'; data: Uint8Array }
28
+ | { id: number; type: 'closeEndpoint' }
29
+ | { id: number; type: 'closeConnection' }
30
+ | { id: number; type: 'networkChange' }
31
+ | { id: number; type: 'startRecvLoop' }
32
+ | { id: number; type: 'stopRecvLoop' }
33
+ | { id: number; type: 'getStats' }
34
+ | { id: number; type: 'terminate' };
35
+
36
+ /** Helper: gửi success response */
37
+ function sendResult(id: number, data?: any) {
38
+ self.postMessage({ id, type: 'result', data });
39
+ }
40
+
41
+ /** Helper: gửi error response */
42
+ function sendError(id: number, error: string) {
43
+ self.postMessage({ id, type: 'error', error });
44
+ }
45
+
46
+ /** Helper: sleep */
47
+ function sleep(ms: number): Promise<void> {
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+
51
+ /**
52
+ * Receive loop — chạy liên tục trong Worker.
53
+ * asyncRecv() block Worker thread khi chờ data — KHÔNG ảnh hưởng Main Thread.
54
+ * Data nhận được gửi về Main Thread qua postMessage với Transferable.
55
+ */
56
+ async function recvLoop() {
57
+ while (isRecvActive && ermisCall) {
58
+ try {
59
+ const data = await ermisCall.asyncRecv();
60
+ // Transfer buffer ownership → zero-copy
61
+ (self as unknown as { postMessage: (msg: any, transfer?: Transferable[]) => void }).postMessage(
62
+ { type: 'recv_data', data },
63
+ [data.buffer],
64
+ );
65
+ } catch (error) {
66
+ if (!isRecvActive) break; // Đã dừng — không cần log
67
+ (self as unknown as { postMessage: (msg: any, transfer?: Transferable[]) => void }).postMessage({
68
+ type: 'recv_error',
69
+ error: String(error),
70
+ });
71
+ await sleep(200); // Backoff trước khi thử lại
72
+ }
73
+ }
74
+ }
75
+
76
+ /** Main message handler */
77
+ self.onmessage = async (e: MessageEvent<WorkerRequest>) => {
78
+ const msg = e.data;
79
+
80
+ try {
81
+ switch (msg.type) {
82
+ // === LIFECYCLE ===
83
+ case 'init': {
84
+ // Nhận WASM bytes từ Main Thread → compile thành Module → initSync
85
+ const wasmModule = new WebAssembly.Module(msg.wasmBytes);
86
+ initSync(wasmModule);
87
+ ermisCall = new ErmisCall();
88
+ sendResult(msg.id);
89
+ break;
90
+ }
91
+
92
+ case 'spawn': {
93
+ if (!ermisCall) throw new Error('WASM not initialized');
94
+ await ermisCall.spawn(msg.relayUrls);
95
+ sendResult(msg.id);
96
+ break;
97
+ }
98
+
99
+ case 'getLocalEndpointAddr': {
100
+ if (!ermisCall) throw new Error('WASM not initialized');
101
+ const addr = await ermisCall.getLocalEndpointAddr();
102
+ sendResult(msg.id, addr);
103
+ break;
104
+ }
105
+
106
+ case 'connect': {
107
+ if (!ermisCall) throw new Error('WASM not initialized');
108
+ await ermisCall.connect(msg.address);
109
+ sendResult(msg.id);
110
+ break;
111
+ }
112
+
113
+ case 'acceptConnection': {
114
+ if (!ermisCall) throw new Error('WASM not initialized');
115
+ await ermisCall.acceptConnection();
116
+ sendResult(msg.id);
117
+ break;
118
+ }
119
+
120
+ // === DATA SEND (blocking calls — now safe in Worker) ===
121
+ case 'sendFrame': {
122
+ if (!ermisCall) throw new Error('WASM not initialized');
123
+ ermisCall.sendFrame(msg.data);
124
+ sendResult(msg.id);
125
+ break;
126
+ }
127
+
128
+ case 'beginWithGop': {
129
+ if (!ermisCall) throw new Error('WASM not initialized');
130
+ ermisCall.beginWithGop(msg.data);
131
+ sendResult(msg.id);
132
+ break;
133
+ }
134
+
135
+ case 'sendAudioFrame': {
136
+ if (!ermisCall) throw new Error('WASM not initialized');
137
+ ermisCall.sendAudioFrame(msg.data);
138
+ sendResult(msg.id);
139
+ break;
140
+ }
141
+
142
+ case 'sendControlFrame': {
143
+ if (!ermisCall) throw new Error('WASM not initialized');
144
+ ermisCall.sendControlFrame(msg.data);
145
+ sendResult(msg.id);
146
+ break;
147
+ }
148
+
149
+ // === RECEIVE LOOP ===
150
+ case 'startRecvLoop': {
151
+ isRecvActive = true;
152
+ sendResult(msg.id);
153
+ // Start loop (fire-and-forget — runs until stopRecvLoop)
154
+ recvLoop();
155
+ break;
156
+ }
157
+
158
+ case 'stopRecvLoop': {
159
+ isRecvActive = false;
160
+ sendResult(msg.id);
161
+ break;
162
+ }
163
+
164
+ // === CONTROL ===
165
+ case 'closeEndpoint': {
166
+ if (!ermisCall) throw new Error('WASM not initialized');
167
+ await ermisCall.closeEndpoint();
168
+ sendResult(msg.id);
169
+ break;
170
+ }
171
+
172
+ case 'closeConnection': {
173
+ if (!ermisCall) throw new Error('WASM not initialized');
174
+ ermisCall.closeConnection();
175
+ sendResult(msg.id);
176
+ break;
177
+ }
178
+
179
+ case 'networkChange': {
180
+ if (!ermisCall) throw new Error('WASM not initialized');
181
+ ermisCall.networkChange();
182
+ sendResult(msg.id);
183
+ break;
184
+ }
185
+
186
+ case 'getStats': {
187
+ if (!ermisCall) throw new Error('WASM not initialized');
188
+ const stats = ermisCall.getStats();
189
+ sendResult(msg.id, stats);
190
+ break;
191
+ }
192
+
193
+ case 'terminate': {
194
+ isRecvActive = false;
195
+ if (ermisCall) {
196
+ try {
197
+ ermisCall.closeConnection();
198
+ } catch {
199
+ /* ignore */
200
+ }
201
+ try {
202
+ ermisCall.free();
203
+ } catch {
204
+ /* ignore */
205
+ }
206
+ ermisCall = null;
207
+ }
208
+ sendResult(msg.id);
209
+ // Worker sẽ bị terminate bởi Main Thread sau khi nhận result
210
+ break;
211
+ }
212
+
213
+ default:
214
+ sendError((msg as any).id, `Unknown message type: ${(msg as any).type}`);
215
+ }
216
+ } catch (error) {
217
+ sendError(msg.id, String(error));
218
+ }
219
+ };
@@ -0,0 +1,244 @@
1
+ /**
2
+ * WasmWorkerProxy — Proxy class chạy trên Main Thread.
3
+ *
4
+ * Implement interface INodeCall, nhưng mọi method call đều được forward
5
+ * tới Web Worker qua postMessage. Main Thread KHÔNG BAO GIỜ gọi WASM trực tiếp.
6
+ *
7
+ * Data transfer dùng Transferable buffers (zero-copy).
8
+ */
9
+
10
+ import { INodeCall } from './types';
11
+
12
+ /** Response types từ Worker */
13
+ type WorkerResponse =
14
+ | { id: number; type: 'result'; data?: any }
15
+ | { id: number; type: 'error'; error: string }
16
+ | { type: 'recv_data'; data: Uint8Array }
17
+ | { type: 'recv_error'; error: string };
18
+
19
+ export class WasmWorkerProxy implements INodeCall {
20
+ private worker: Worker;
21
+ private nextId = 0;
22
+ private pendingCalls = new Map<number, { resolve: (data?: any) => void; reject: (error: Error) => void }>();
23
+
24
+ /** Queue cho asyncRecv — Worker gửi data về, Main Thread consume từng cái */
25
+ private recvResolveQueue: Array<(data: Uint8Array) => void> = [];
26
+ private recvDataQueue: Uint8Array[] = [];
27
+ private recvErrorQueue: Array<(error: Error) => void> = [];
28
+
29
+ /** Static cache — persist across Worker instances */
30
+ private static cachedBlobUrl: string | null = null;
31
+ private static cachedWasmBytes: ArrayBuffer | null = null;
32
+
33
+ constructor(workerUrl: string | URL) {
34
+ // Cache Blob URL — chỉ fetch worker script 1 lần
35
+ if (!WasmWorkerProxy.cachedBlobUrl) {
36
+ const url = workerUrl.toString();
37
+ const xhr = new XMLHttpRequest();
38
+ xhr.open('GET', url, false); // synchronous
39
+ xhr.send();
40
+
41
+ if (xhr.status === 200) {
42
+ const blob = new Blob([xhr.responseText], { type: 'application/javascript' });
43
+ WasmWorkerProxy.cachedBlobUrl = URL.createObjectURL(blob);
44
+ }
45
+ }
46
+
47
+ if (WasmWorkerProxy.cachedBlobUrl) {
48
+ this.worker = new Worker(WasmWorkerProxy.cachedBlobUrl, { type: 'module' });
49
+ } else {
50
+ // Fallback: try direct URL (works when server has correct MIME config)
51
+ this.worker = new Worker(workerUrl, { type: 'module' });
52
+ }
53
+
54
+ this.worker.onmessage = (e: MessageEvent<WorkerResponse>) => this.handleMessage(e.data);
55
+ this.worker.onerror = (e) => {
56
+ console.error('🔴 WASM Worker error:', e.message);
57
+ // Reject all pending calls
58
+ this.pendingCalls.forEach(({ reject }) => reject(new Error(`Worker error: ${e.message}`)));
59
+ this.pendingCalls.clear();
60
+ };
61
+ }
62
+
63
+ // === LIFECYCLE ===
64
+
65
+ /** Initialize WASM trong Worker — fetch bytes 1 lần, gửi cached bytes cho Worker */
66
+ async init(wasmPath?: string): Promise<void> {
67
+ // Fetch WASM bytes 1 lần duy nhất trên Main Thread
68
+ if (!WasmWorkerProxy.cachedWasmBytes) {
69
+ const absoluteWasmPath = new URL(
70
+ wasmPath || '/ermis_call_node_wasm_bg.wasm',
71
+ window.location.origin,
72
+ ).href;
73
+ const response = await fetch(absoluteWasmPath);
74
+ WasmWorkerProxy.cachedWasmBytes = await response.arrayBuffer();
75
+ }
76
+ // Gửi copy bytes cho Worker (transfer → zero-copy, original stays cached)
77
+ const bytesCopy = WasmWorkerProxy.cachedWasmBytes.slice(0);
78
+ await this.call('init', { wasmBytes: bytesCopy }, [bytesCopy]);
79
+ }
80
+
81
+ /** Spawn WASM node */
82
+ async spawn(relayUrls: string[]): Promise<void> {
83
+ await this.call('spawn', { relayUrls });
84
+ }
85
+
86
+ /** Lấy local endpoint address */
87
+ async getLocalEndpointAddr(): Promise<string> {
88
+ return await this.call('getLocalEndpointAddr');
89
+ }
90
+
91
+ // === INodeCall IMPLEMENTATION ===
92
+
93
+ async connect(address: string): Promise<void> {
94
+ await this.call('connect', { address });
95
+ }
96
+
97
+ async acceptConnection(): Promise<void> {
98
+ await this.call('acceptConnection');
99
+ }
100
+
101
+ async sendFrame(data: Uint8Array): Promise<void> {
102
+ // Transfer buffer ownership → zero-copy Main→Worker
103
+ await this.call('sendFrame', { data }, [data.buffer]);
104
+ }
105
+
106
+ async beginWithGop(data: Uint8Array): Promise<void> {
107
+ await this.call('beginWithGop', { data }, [data.buffer]);
108
+ }
109
+
110
+ async sendAudioFrame(data: Uint8Array): Promise<void> {
111
+ await this.call('sendAudioFrame', { data }, [data.buffer]);
112
+ }
113
+
114
+ async sendControlFrame(data: Uint8Array): Promise<void> {
115
+ // Control frames nhỏ — clone OK, không cần transfer
116
+ await this.call('sendControlFrame', { data });
117
+ }
118
+
119
+ /**
120
+ * asyncRecv — nhận data từ Worker recv loop.
121
+ *
122
+ * Worker chạy recv loop nội bộ, gửi data về qua postMessage.
123
+ * Method này trả về Promise resolve khi có data mới.
124
+ * KHÔNG BAO GIỜ block Main Thread — chỉ chờ postMessage event.
125
+ */
126
+ async asyncRecv(): Promise<Uint8Array> {
127
+ // Nếu đã có data trong queue (Worker gửi trước khi Main gọi asyncRecv)
128
+ if (this.recvDataQueue.length > 0) {
129
+ return this.recvDataQueue.shift()!;
130
+ }
131
+
132
+ // Chờ data từ Worker
133
+ return new Promise<Uint8Array>((resolve, reject) => {
134
+ this.recvResolveQueue.push(resolve);
135
+ this.recvErrorQueue.push(reject);
136
+ });
137
+ }
138
+
139
+ // === CONTROL ===
140
+
141
+ /** Bắt đầu recv loop trong Worker */
142
+ async startRecvLoop(): Promise<void> {
143
+ await this.call('startRecvLoop');
144
+ }
145
+
146
+ /** Dừng recv loop trong Worker */
147
+ async stopRecvLoop(): Promise<void> {
148
+ await this.call('stopRecvLoop');
149
+ }
150
+
151
+ async closeEndpoint(): Promise<void> {
152
+ await this.call('closeEndpoint');
153
+ }
154
+
155
+ closeConnection(): void {
156
+ // Fire-and-forget — không cần chờ response
157
+ const id = this.nextId++;
158
+ this.worker.postMessage({ id, type: 'closeConnection' });
159
+ }
160
+
161
+ networkChange(): void {
162
+ const id = this.nextId++;
163
+ this.worker.postMessage({ id, type: 'networkChange' });
164
+ }
165
+
166
+ async getStats(): Promise<any> {
167
+ return await this.call('getStats');
168
+ }
169
+
170
+ /** Terminate Worker — gọi khi call kết thúc */
171
+ async terminate(): Promise<void> {
172
+ try {
173
+ await this.call('terminate');
174
+ } catch {
175
+ /* ignore — worker may already be dead */
176
+ }
177
+ this.worker.terminate();
178
+
179
+ // KHÔNG revoke Blob URL và KHÔNG clear cached Module
180
+ // — chúng được reuse cho call tiếp theo
181
+
182
+ // Reject remaining recv waiters
183
+ this.recvResolveQueue = [];
184
+ this.recvErrorQueue.forEach((reject) => reject(new Error('Worker terminated')));
185
+ this.recvErrorQueue = [];
186
+ this.recvDataQueue = [];
187
+ }
188
+
189
+ // === INTERNAL ===
190
+
191
+ private handleMessage(msg: WorkerResponse) {
192
+ // Stream data từ recv loop (không có id)
193
+ if (msg.type === 'recv_data') {
194
+ const data = (msg as any).data as Uint8Array;
195
+ if (this.recvResolveQueue.length > 0) {
196
+ // Có consumer đang chờ → resolve ngay
197
+ const resolve = this.recvResolveQueue.shift()!;
198
+ this.recvErrorQueue.shift(); // Remove paired reject
199
+ resolve(data);
200
+ } else {
201
+ // Chưa có consumer → queue data
202
+ this.recvDataQueue.push(data);
203
+ }
204
+ return;
205
+ }
206
+
207
+ if (msg.type === 'recv_error') {
208
+ const error = new Error((msg as any).error);
209
+ if (this.recvErrorQueue.length > 0) {
210
+ const reject = this.recvErrorQueue.shift()!;
211
+ this.recvResolveQueue.shift(); // Remove paired resolve
212
+ reject(error);
213
+ }
214
+ return;
215
+ }
216
+
217
+ // RPC response (có id)
218
+ const { id } = msg;
219
+ const pending = this.pendingCalls.get(id);
220
+ if (!pending) return;
221
+
222
+ this.pendingCalls.delete(id);
223
+
224
+ if (msg.type === 'error') {
225
+ pending.reject(new Error(msg.error));
226
+ } else {
227
+ pending.resolve(msg.data);
228
+ }
229
+ }
230
+
231
+ /** Send RPC call to Worker and wait for response */
232
+ private call(type: string, payload?: Record<string, any>, transfer?: Transferable[]): Promise<any> {
233
+ const id = this.nextId++;
234
+ return new Promise((resolve, reject) => {
235
+ this.pendingCalls.set(id, { resolve, reject });
236
+ const message = { id, type, ...payload };
237
+ if (transfer && transfer.length > 0) {
238
+ this.worker.postMessage(message, transfer);
239
+ } else {
240
+ this.worker.postMessage(message);
241
+ }
242
+ });
243
+ }
244
+ }