@tangle-network/sandbox-ui 0.12.0 → 0.14.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.
@@ -3,6 +3,38 @@ import { useState, useEffect, useRef, useCallback } from "react";
3
3
  function createEmptyBatch() {
4
4
  return { data: "", waiters: [] };
5
5
  }
6
+ var WS_OPEN_TIMEOUT_MS = 1500;
7
+ function toWsUrl(apiUrl, sessionId) {
8
+ try {
9
+ const url = new URL(`${apiUrl}/terminals/${sessionId}/ws`);
10
+ if (url.protocol === "https:") url.protocol = "wss:";
11
+ else if (url.protocol === "http:") url.protocol = "ws:";
12
+ else return null;
13
+ return url.toString();
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+ var BEARER_SUBPROTOCOL_PREFIX = "bearer.";
19
+ function toBearerSubprotocol(token) {
20
+ if (typeof btoa === "undefined") return null;
21
+ let encoded;
22
+ try {
23
+ encoded = btoa(token);
24
+ } catch {
25
+ return null;
26
+ }
27
+ return `${BEARER_SUBPROTOCOL_PREFIX}${encoded.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
28
+ }
29
+ var stdinEncoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
30
+ function encodeStdin(text) {
31
+ if (!stdinEncoder) {
32
+ throw new Error(
33
+ "TextEncoder is unavailable; WebSocket transport cannot encode stdin"
34
+ );
35
+ }
36
+ return stdinEncoder.encode(text);
37
+ }
6
38
  function usePtySession({ apiUrl, token, onData }) {
7
39
  const [isConnected, setIsConnected] = useState(false);
8
40
  const [error, setError] = useState(null);
@@ -13,6 +45,8 @@ function usePtySession({ apiUrl, token, onData }) {
13
45
  const mountedRef = useRef(true);
14
46
  const onDataRef = useRef(onData);
15
47
  const connectStreamRef = useRef(null);
48
+ const wsRef = useRef(null);
49
+ const pendingWsRef = useRef(null);
16
50
  const pendingBatchRef = useRef(createEmptyBatch());
17
51
  const drainPromiseRef = useRef(null);
18
52
  const inputAbortRef = useRef(null);
@@ -37,8 +71,27 @@ function usePtySession({ apiUrl, token, onData }) {
37
71
  abortRef.current = null;
38
72
  }
39
73
  }, []);
74
+ const closeWs = useCallback(() => {
75
+ const active = wsRef.current;
76
+ if (active) {
77
+ wsRef.current = null;
78
+ try {
79
+ active.close();
80
+ } catch {
81
+ }
82
+ }
83
+ const pending = pendingWsRef.current;
84
+ if (pending) {
85
+ pendingWsRef.current = null;
86
+ try {
87
+ pending.close();
88
+ } catch {
89
+ }
90
+ }
91
+ }, []);
40
92
  const cleanup = useCallback(() => {
41
93
  abortStream();
94
+ closeWs();
42
95
  if (inputAbortRef.current) {
43
96
  inputAbortRef.current.abort();
44
97
  inputAbortRef.current = null;
@@ -55,7 +108,110 @@ function usePtySession({ apiUrl, token, onData }) {
55
108
  }
56
109
  rejectPendingInput("Terminal session is not connected");
57
110
  setIsConnected(false);
58
- }, [apiUrl, token, abortStream, rejectPendingInput]);
111
+ }, [apiUrl, token, abortStream, closeWs, rejectPendingInput]);
112
+ const connectWs = useCallback((sessionId) => {
113
+ return new Promise((resolve) => {
114
+ if (typeof WebSocket === "undefined" || stdinEncoder === null) {
115
+ resolve(false);
116
+ return;
117
+ }
118
+ const wsUrl = toWsUrl(apiUrl, sessionId);
119
+ if (!wsUrl) {
120
+ resolve(false);
121
+ return;
122
+ }
123
+ const subprotocol = toBearerSubprotocol(token);
124
+ if (!subprotocol) {
125
+ resolve(false);
126
+ return;
127
+ }
128
+ let ws;
129
+ try {
130
+ ws = new WebSocket(wsUrl, [subprotocol]);
131
+ } catch {
132
+ resolve(false);
133
+ return;
134
+ }
135
+ ws.binaryType = "arraybuffer";
136
+ pendingWsRef.current = ws;
137
+ let opened = false;
138
+ let settled = false;
139
+ const settle = (ok) => {
140
+ if (settled) return;
141
+ settled = true;
142
+ clearTimeout(handshakeTimer);
143
+ resolve(ok);
144
+ };
145
+ const handshakeTimer = setTimeout(() => {
146
+ if (opened) return;
147
+ try {
148
+ ws.close();
149
+ } catch {
150
+ }
151
+ settle(false);
152
+ }, WS_OPEN_TIMEOUT_MS);
153
+ ws.onopen = () => {
154
+ opened = true;
155
+ if (pendingWsRef.current !== ws || !mountedRef.current) {
156
+ try {
157
+ ws.close();
158
+ } catch {
159
+ }
160
+ settle(false);
161
+ return;
162
+ }
163
+ pendingWsRef.current = null;
164
+ wsRef.current = ws;
165
+ setIsConnected(true);
166
+ setError(null);
167
+ retryCountRef.current = 0;
168
+ settle(true);
169
+ };
170
+ ws.onmessage = (ev) => {
171
+ if (!mountedRef.current) return;
172
+ const data = ev.data;
173
+ let text;
174
+ if (typeof data === "string") {
175
+ text = data;
176
+ } else if (data instanceof ArrayBuffer) {
177
+ text = new TextDecoder().decode(data);
178
+ } else if (ArrayBuffer.isView(data)) {
179
+ text = new TextDecoder().decode(data);
180
+ } else {
181
+ data.text().then((t) => {
182
+ if (mountedRef.current) onDataRef.current(t);
183
+ }).catch(() => {
184
+ });
185
+ return;
186
+ }
187
+ if (text) onDataRef.current(text);
188
+ };
189
+ ws.onerror = () => {
190
+ };
191
+ ws.onclose = () => {
192
+ if (pendingWsRef.current === ws) {
193
+ pendingWsRef.current = null;
194
+ }
195
+ const wasActive = wsRef.current === ws;
196
+ if (wasActive) {
197
+ wsRef.current = null;
198
+ }
199
+ if (!opened) {
200
+ settle(false);
201
+ return;
202
+ }
203
+ if (!wasActive || !mountedRef.current) return;
204
+ setIsConnected(false);
205
+ if (sessionIdRef.current) {
206
+ retryTimerRef.current = setTimeout(() => {
207
+ if (mountedRef.current && sessionIdRef.current) {
208
+ connectStreamRef.current?.(sessionIdRef.current);
209
+ }
210
+ }, 1e3);
211
+ }
212
+ };
213
+ });
214
+ }, [apiUrl, token]);
59
215
  const connectStream = useCallback(async (sessionId) => {
60
216
  abortStream();
61
217
  setError(null);
@@ -162,7 +318,12 @@ function usePtySession({ apiUrl, token, onData }) {
162
318
  if (!mountedRef.current) return;
163
319
  sessionIdRef.current = sessionId;
164
320
  inputAbortRef.current = new AbortController();
321
+ const wsOk = await connectWs(sessionId);
322
+ if (!mountedRef.current || sessionIdRef.current !== sessionId) return;
165
323
  ensureDrainRunningRef.current?.();
324
+ if (wsOk) {
325
+ return;
326
+ }
166
327
  await connectStream(sessionId);
167
328
  } catch (err) {
168
329
  if (err.name === "AbortError") return;
@@ -172,10 +333,19 @@ function usePtySession({ apiUrl, token, onData }) {
172
333
  setIsConnected(false);
173
334
  }
174
335
  }
175
- }, [apiUrl, token, cleanup, connectStream]);
336
+ }, [apiUrl, token, cleanup, connectWs, connectStream]);
176
337
  const resizeTerminal = useCallback(async (cols, rows) => {
177
338
  const sid = sessionIdRef.current;
178
339
  if (!sid || cols <= 0 || rows <= 0) return;
340
+ const ws = wsRef.current;
341
+ if (ws && ws.readyState === WebSocket.OPEN) {
342
+ try {
343
+ ws.send(JSON.stringify({ type: "resize", cols, rows }));
344
+ return;
345
+ } catch (err) {
346
+ console.warn("Terminal resize over WS failed; falling back to HTTP", err);
347
+ }
348
+ }
179
349
  try {
180
350
  const res = await fetch(`${apiUrl}/terminals/${sid}`, {
181
351
  method: "PATCH",
@@ -201,6 +371,16 @@ function usePtySession({ apiUrl, token, onData }) {
201
371
  }
202
372
  const batch = pendingBatchRef.current;
203
373
  pendingBatchRef.current = createEmptyBatch();
374
+ const ws = wsRef.current;
375
+ if (ws && ws.readyState === WebSocket.OPEN) {
376
+ try {
377
+ ws.send(encodeStdin(batch.data));
378
+ for (const w of batch.waiters) w.resolve();
379
+ } catch (err) {
380
+ for (const w of batch.waiters) w.reject(err);
381
+ }
382
+ continue;
383
+ }
204
384
  try {
205
385
  const res = await fetch(`${apiUrl}/terminals/${sid}/input`, {
206
386
  method: "POST",