@tangle-network/sandbox-ui 0.113.7 → 0.115.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/README.md +40 -0
- package/dist/WorkflowGraph-B-NsdYz_.js +1961 -0
- package/dist/assets.d.ts +214 -206
- package/dist/assets.js +1057 -908
- package/dist/auth-CWe17Hly.js +37 -0
- package/dist/auth.d.ts +2 -30
- package/dist/auth.js +2 -15
- package/dist/chat-BOvFAe6p.js +903 -0
- package/dist/chat.d.ts +2 -230
- package/dist/chat.js +2 -34
- package/dist/connectors.d.ts +52 -49
- package/dist/connectors.js +595 -643
- package/dist/dashboard-CZ3VgNPR.js +4969 -0
- package/dist/dashboard.d.ts +4 -1297
- package/dist/dashboard.js +4 -152
- package/dist/editor.d.ts +2 -1
- package/dist/editor.js +2 -31
- package/dist/files.d.ts +2 -6
- package/dist/files.js +2 -33
- package/dist/globals.css +75 -3
- package/dist/hooks.d.ts +146 -148
- package/dist/hooks.js +222 -55
- package/dist/index-CKRaj1pM.d.ts +776 -0
- package/dist/index-CXX5IpH2.d.ts +2 -0
- package/dist/index-CesPlOyx.d.ts +367 -0
- package/dist/index-DPePq7c8.d.ts +1340 -0
- package/dist/index-DiyLLZuJ.d.ts +230 -0
- package/dist/index-DvmZLMT1.d.ts +30 -0
- package/dist/index-PRM636Ex.d.ts +86 -0
- package/dist/index.d.ts +21 -33
- package/dist/index.js +21 -701
- package/dist/info-panel-C9cqDzOL.js +594 -0
- package/dist/integrations.d.ts +133 -141
- package/dist/integrations.js +491 -673
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -13
- package/dist/model-I-NIxeM-.js +1250 -0
- package/dist/model-brand-B9A4H9pJ.d.ts +98 -0
- package/dist/model-brand-hMyDTUqB.js +624 -0
- package/dist/motion-DkyBRJb9.js +37 -0
- package/dist/openui.d.ts +2 -1
- package/dist/openui.js +2 -7
- package/dist/pages.d.ts +307 -300
- package/dist/pages.js +4404 -4181
- package/dist/primitives-Dh1vG6J3.js +113 -0
- package/dist/primitives.d.ts +2 -74
- package/dist/primitives.js +2 -204
- package/dist/provider-logo-DjqmConF.js +311 -0
- package/dist/run.d.ts +2 -1
- package/dist/run.js +2 -23
- package/dist/sdk-hooks.d.ts +3 -4
- package/dist/sdk-hooks.js +3 -31
- package/dist/sidecar-DgH_vQ6m.d.ts +9 -0
- package/dist/stores.d.ts +14 -15
- package/dist/stores.js +13 -81
- package/dist/styles.css +75 -3
- package/dist/template-card-L-4gmjx2.d.ts +90 -0
- package/dist/terminal-CN9ZgHRt.js +197 -0
- package/dist/terminal.d.ts +60 -60
- package/dist/terminal.js +2 -10
- package/dist/tokens.css +51 -0
- package/dist/types.d.ts +4 -11
- package/dist/types.js +0 -1
- package/dist/use-pty-session-C8cE0adT.js +669 -0
- package/dist/use-sidecar-auth-CCTJcrq7.d.ts +145 -0
- package/dist/use-sidecar-auth-DkdcufOz.js +742 -0
- package/dist/utils-DzWbktWG.js +2 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +2 -37
- package/dist/workbench-ztU44Jt2.js +8455 -0
- package/dist/workbench.d.ts +2 -310
- package/dist/workbench.js +2 -44
- package/dist/workflows.d.ts +341 -339
- package/dist/workflows.js +74 -109
- package/dist/workspace-B6AtI6ld.js +2510 -0
- package/dist/workspace.d.ts +2 -761
- package/dist/workspace.js +2 -51
- package/package.json +46 -40
- package/dist/WorkflowGraph-2QOORYIR.js +0 -1722
- package/dist/chunk-2BUPSB7O.js +0 -0
- package/dist/chunk-3KBNPQQF.js +0 -2697
- package/dist/chunk-3LIXVEEE.js +0 -279
- package/dist/chunk-72UF2H5H.js +0 -707
- package/dist/chunk-AI25G5MX.js +0 -944
- package/dist/chunk-ANSC5AFC.js +0 -314
- package/dist/chunk-AZ3AWMTM.js +0 -8
- package/dist/chunk-BGVEHENC.js +0 -10
- package/dist/chunk-CZ7CSFO4.js +0 -39
- package/dist/chunk-DYXXYGS4.js +0 -4700
- package/dist/chunk-EI44GEQ5.js +0 -6
- package/dist/chunk-IACRDXER.js +0 -219
- package/dist/chunk-IOB2PW5Z.js +0 -55
- package/dist/chunk-M4K7ZGR2.js +0 -24
- package/dist/chunk-MYZAW4RX.js +0 -665
- package/dist/chunk-OMUIPJQZ.js +0 -555
- package/dist/chunk-RQZRXKMR.js +0 -398
- package/dist/chunk-SOKKTB7W.js +0 -230
- package/dist/chunk-TADGS4Q7.js +0 -1010
- package/dist/chunk-UCQNOUN3.js +0 -7964
- package/dist/chunk-ULPLRBMF.js +0 -34
- package/dist/chunk-WID73FPH.js +0 -89
- package/dist/chunk-X3YXP7FG.js +0 -38
- package/dist/logo-DiAjq759.d.ts +0 -15
- package/dist/model-brand-BNnaUKKZ.d.ts +0 -99
- package/dist/ports-list-CDIvTNnX.d.ts +0 -17
- package/dist/sdk-hooks-Djkjq4cp.d.ts +0 -145
- package/dist/template-card-Bn-Bw5xC.d.ts +0 -87
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
//#region src/hooks/use-pty-session.ts
|
|
3
|
+
function createEmptyBatch() {
|
|
4
|
+
return {
|
|
5
|
+
data: "",
|
|
6
|
+
waiters: []
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Time we wait for the terminal WebSocket to reach server-confirmed READY
|
|
11
|
+
* before falling back to the HTTP+SSE transport. The cold terminal-WS
|
|
12
|
+
* upgrade can traverse several proxy hops (browser → app worker → sandbox
|
|
13
|
+
* edge → orchestrator dial → host-agent → sidecar) and take a few seconds
|
|
14
|
+
* to establish; the budget must cover the full OPEN → init → ready exchange
|
|
15
|
+
* so the direct WS path wins instead of dropping through to the fallback
|
|
16
|
+
* dial on a cold connect.
|
|
17
|
+
*/
|
|
18
|
+
const WS_READY_TIMEOUT_MS = 1e4;
|
|
19
|
+
/** Convert an http(s) base URL into the matching ws(s) URL. */
|
|
20
|
+
function toWsUrl(apiUrl, sessionId) {
|
|
21
|
+
try {
|
|
22
|
+
const base = typeof window !== "undefined" ? window.location.href : void 0;
|
|
23
|
+
const url = new URL(`${apiUrl}/terminals/${sessionId}/ws`, base);
|
|
24
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
25
|
+
else if (url.protocol === "http:") url.protocol = "ws:";
|
|
26
|
+
else return null;
|
|
27
|
+
return url.toString();
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Encode the bearer token as a WebSocket subprotocol identifier.
|
|
34
|
+
*
|
|
35
|
+
* Browsers cannot set the `Authorization` header on a WebSocket
|
|
36
|
+
* upgrade. The naive workaround — putting the token in a query
|
|
37
|
+
* parameter — is a real security risk: query strings are routinely
|
|
38
|
+
* captured in edge-proxy access logs, browser DevTools network
|
|
39
|
+
* panels, referrer headers on internal links, and log-aggregation
|
|
40
|
+
* systems where they may be retained for years. Move the token to
|
|
41
|
+
* `Sec-WebSocket-Protocol` instead, which is a request *header* and
|
|
42
|
+
* is treated like other auth headers by the same systems (i.e. not
|
|
43
|
+
* surfaced in URL-shaped logs).
|
|
44
|
+
*
|
|
45
|
+
* Per RFC 7230 a subprotocol identifier is a `token`, which excludes
|
|
46
|
+
* `+`, `/`, and `=`. Bearer tokens minted by the sandbox API are
|
|
47
|
+
* not guaranteed to satisfy that grammar, so we base64url-encode the
|
|
48
|
+
* value first. The matching server reverses the encoding before
|
|
49
|
+
* validating.
|
|
50
|
+
*
|
|
51
|
+
* The credential is offered alongside {@link TERMINAL_WS_ECHO_SUBPROTOCOL}.
|
|
52
|
+
* A server must never echo `bearer.<…>` — that would put the live credential
|
|
53
|
+
* in a response header — so it selects the marker instead. The marker is
|
|
54
|
+
* required, not decorative: Chrome fails a handshake with "Sent non-empty
|
|
55
|
+
* 'Sec-WebSocket-Protocol' header but no response was received" when the
|
|
56
|
+
* client offered subprotocols and the server selected none, so a
|
|
57
|
+
* credential-only offer can never complete against a correct server.
|
|
58
|
+
*/
|
|
59
|
+
const BEARER_SUBPROTOCOL_PREFIX = "bearer.";
|
|
60
|
+
/**
|
|
61
|
+
* Non-credential subprotocol the server echoes into the 101 to satisfy the
|
|
62
|
+
* client's subprotocol negotiation. Must match the value the backends select
|
|
63
|
+
* (`TERMINAL_WS_ECHO_SUBPROTOCOL` in sandbox-api / the sidecar's
|
|
64
|
+
* `ws-subprotocol-auth`).
|
|
65
|
+
*/
|
|
66
|
+
const TERMINAL_WS_ECHO_SUBPROTOCOL = "tangle.terminal.v1";
|
|
67
|
+
function toBearerSubprotocol(token) {
|
|
68
|
+
if (typeof btoa === "undefined") return null;
|
|
69
|
+
let encoded;
|
|
70
|
+
try {
|
|
71
|
+
encoded = btoa(token);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return `${BEARER_SUBPROTOCOL_PREFIX}${encoded.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
|
|
76
|
+
}
|
|
77
|
+
const DEFAULT_TERMINAL_COLS = 80;
|
|
78
|
+
const DEFAULT_TERMINAL_ROWS = 24;
|
|
79
|
+
function createTerminalConnectionId() {
|
|
80
|
+
const cryptoApi = globalThis.crypto;
|
|
81
|
+
if (cryptoApi && typeof cryptoApi.randomUUID === "function") return `terminal-${cryptoApi.randomUUID()}`;
|
|
82
|
+
return `terminal-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
83
|
+
}
|
|
84
|
+
function interactiveIdentityKey(incarnationId, control) {
|
|
85
|
+
if (incarnationId === void 0 && control === void 0) return "plain";
|
|
86
|
+
if (incarnationId === void 0 || control === void 0) return `incomplete:${incarnationId ?? ""}`;
|
|
87
|
+
return [
|
|
88
|
+
incarnationId,
|
|
89
|
+
control.refDigest,
|
|
90
|
+
String(control.generation),
|
|
91
|
+
control.leaseId,
|
|
92
|
+
control.holderId,
|
|
93
|
+
control.expiresAt
|
|
94
|
+
].join("\0");
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Manages a PTY session against the sidecar terminal API.
|
|
98
|
+
*
|
|
99
|
+
* Transport:
|
|
100
|
+
* 1. Try the current sidecar WebSocket contract directly:
|
|
101
|
+
* GET /terminals/:id/ws, then send `{"type":"init",...}`.
|
|
102
|
+
* - Server → client: BINARY frames carrying PTY output; TEXT frames
|
|
103
|
+
* carrying lifecycle/control messages.
|
|
104
|
+
* - Client → server: TEXT frames carrying JSON input/resize messages.
|
|
105
|
+
* 2. If direct WS does not reach server `ready` within
|
|
106
|
+
* WS_READY_TIMEOUT_MS, or it errors before ready, fall back to the older
|
|
107
|
+
* terminal contract:
|
|
108
|
+
* - POST /terminals creates the session.
|
|
109
|
+
* - GET /terminals/:id/ws tries the older WS transport.
|
|
110
|
+
* - GET /terminals/:id/stream (SSE for output)
|
|
111
|
+
* - POST /terminals/:id/input (one batched POST at a time)
|
|
112
|
+
* - PATCH /terminals/:id (resize)
|
|
113
|
+
* 3. DELETE /terminals/:id closes sessions created by the older REST API.
|
|
114
|
+
*
|
|
115
|
+
* The WS path eliminates the per-keystroke HTTP round-trip that
|
|
116
|
+
* dominates typing latency through edge proxies; the HTTP+SSE path is
|
|
117
|
+
* preserved as a fallback so the hook keeps working against
|
|
118
|
+
* deployments that have not yet shipped the WS endpoint.
|
|
119
|
+
*/
|
|
120
|
+
function usePtySession({ apiUrl, token, onData, connectionId: providedConnectionId, incarnationId, control }) {
|
|
121
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
122
|
+
const [error, setError] = useState(null);
|
|
123
|
+
const sessionIdRef = useRef(null);
|
|
124
|
+
const connectGenRef = useRef(0);
|
|
125
|
+
const abortRef = useRef(null);
|
|
126
|
+
const retryTimerRef = useRef(void 0);
|
|
127
|
+
const retryCountRef = useRef(0);
|
|
128
|
+
const mountedRef = useRef(true);
|
|
129
|
+
const onDataRef = useRef(onData);
|
|
130
|
+
const tokenRef = useRef(token);
|
|
131
|
+
const incarnationIdRef = useRef(incarnationId);
|
|
132
|
+
const controlRef = useRef(control);
|
|
133
|
+
const exactAttachRequiredRef = useRef(incarnationId !== void 0 || control !== void 0);
|
|
134
|
+
const connectStreamRef = useRef(null);
|
|
135
|
+
const reconnectRef = useRef(null);
|
|
136
|
+
const shouldDeleteSessionRef = useRef(false);
|
|
137
|
+
const transportReadyRef = useRef(false);
|
|
138
|
+
const protocolFailureRef = useRef(false);
|
|
139
|
+
const colsRef = useRef(DEFAULT_TERMINAL_COLS);
|
|
140
|
+
const rowsRef = useRef(DEFAULT_TERMINAL_ROWS);
|
|
141
|
+
const wsRef = useRef(null);
|
|
142
|
+
const pendingWsRef = useRef(null);
|
|
143
|
+
const geometryPendingRef = useRef(false);
|
|
144
|
+
const pendingBatchRef = useRef(createEmptyBatch());
|
|
145
|
+
const drainPromiseRef = useRef(null);
|
|
146
|
+
const inputAbortRef = useRef(null);
|
|
147
|
+
const ensureDrainRunningRef = useRef(null);
|
|
148
|
+
const rejectPendingInput = useCallback((reason) => {
|
|
149
|
+
const batch = pendingBatchRef.current;
|
|
150
|
+
if (batch.waiters.length === 0) {
|
|
151
|
+
pendingBatchRef.current = createEmptyBatch();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
pendingBatchRef.current = createEmptyBatch();
|
|
155
|
+
const err = new Error(reason);
|
|
156
|
+
for (const w of batch.waiters) w.reject(err);
|
|
157
|
+
}, []);
|
|
158
|
+
const abortStream = useCallback(() => {
|
|
159
|
+
if (retryTimerRef.current) {
|
|
160
|
+
clearTimeout(retryTimerRef.current);
|
|
161
|
+
retryTimerRef.current = void 0;
|
|
162
|
+
}
|
|
163
|
+
if (abortRef.current) {
|
|
164
|
+
abortRef.current.abort();
|
|
165
|
+
abortRef.current = null;
|
|
166
|
+
}
|
|
167
|
+
}, []);
|
|
168
|
+
const closeWs = useCallback(() => {
|
|
169
|
+
const active = wsRef.current;
|
|
170
|
+
if (active) {
|
|
171
|
+
wsRef.current = null;
|
|
172
|
+
try {
|
|
173
|
+
active.close();
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
const pending = pendingWsRef.current;
|
|
177
|
+
if (pending) {
|
|
178
|
+
pendingWsRef.current = null;
|
|
179
|
+
try {
|
|
180
|
+
pending.close();
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
}, []);
|
|
184
|
+
const cleanup = useCallback(() => {
|
|
185
|
+
abortStream();
|
|
186
|
+
closeWs();
|
|
187
|
+
if (inputAbortRef.current) {
|
|
188
|
+
inputAbortRef.current.abort();
|
|
189
|
+
inputAbortRef.current = null;
|
|
190
|
+
}
|
|
191
|
+
transportReadyRef.current = false;
|
|
192
|
+
geometryPendingRef.current = false;
|
|
193
|
+
if (sessionIdRef.current) {
|
|
194
|
+
const sid = sessionIdRef.current;
|
|
195
|
+
const shouldDeleteSession = shouldDeleteSessionRef.current;
|
|
196
|
+
sessionIdRef.current = null;
|
|
197
|
+
shouldDeleteSessionRef.current = false;
|
|
198
|
+
if (shouldDeleteSession) fetch(`${apiUrl}/terminals/${sid}`, {
|
|
199
|
+
method: "DELETE",
|
|
200
|
+
headers: { Authorization: `Bearer ${tokenRef.current}` },
|
|
201
|
+
credentials: "include"
|
|
202
|
+
}).catch(() => {});
|
|
203
|
+
}
|
|
204
|
+
rejectPendingInput("Terminal session is not connected");
|
|
205
|
+
setIsConnected(false);
|
|
206
|
+
}, [
|
|
207
|
+
apiUrl,
|
|
208
|
+
abortStream,
|
|
209
|
+
closeWs,
|
|
210
|
+
rejectPendingInput
|
|
211
|
+
]);
|
|
212
|
+
const connectWs = useCallback((sessionId, options = {}) => {
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
if (typeof WebSocket === "undefined") {
|
|
215
|
+
resolve(false);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const wsUrl = toWsUrl(apiUrl, sessionId);
|
|
219
|
+
if (!wsUrl) {
|
|
220
|
+
resolve(false);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const subprotocol = toBearerSubprotocol(tokenRef.current);
|
|
224
|
+
if (!subprotocol) {
|
|
225
|
+
resolve(false);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
let ws;
|
|
229
|
+
try {
|
|
230
|
+
ws = new WebSocket(wsUrl, [TERMINAL_WS_ECHO_SUBPROTOCOL, subprotocol]);
|
|
231
|
+
} catch {
|
|
232
|
+
resolve(false);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
ws.binaryType = "arraybuffer";
|
|
236
|
+
pendingWsRef.current = ws;
|
|
237
|
+
const isCurrentSocket = () => wsRef.current === ws || pendingWsRef.current === ws;
|
|
238
|
+
let opened = false;
|
|
239
|
+
let settled = false;
|
|
240
|
+
const settle = (ok) => {
|
|
241
|
+
if (settled) return;
|
|
242
|
+
settled = true;
|
|
243
|
+
clearTimeout(handshakeTimer);
|
|
244
|
+
resolve(ok);
|
|
245
|
+
};
|
|
246
|
+
const handshakeTimer = setTimeout(() => {
|
|
247
|
+
try {
|
|
248
|
+
ws.close();
|
|
249
|
+
} catch {}
|
|
250
|
+
settle(false);
|
|
251
|
+
}, WS_READY_TIMEOUT_MS);
|
|
252
|
+
const failProtocol = (message) => {
|
|
253
|
+
protocolFailureRef.current = true;
|
|
254
|
+
transportReadyRef.current = false;
|
|
255
|
+
if (wsRef.current === ws) wsRef.current = null;
|
|
256
|
+
setIsConnected(false);
|
|
257
|
+
setError(message);
|
|
258
|
+
rejectPendingInput(message);
|
|
259
|
+
try {
|
|
260
|
+
ws.close();
|
|
261
|
+
} catch {}
|
|
262
|
+
settle(false);
|
|
263
|
+
};
|
|
264
|
+
ws.onopen = () => {
|
|
265
|
+
opened = true;
|
|
266
|
+
if (pendingWsRef.current !== ws || !mountedRef.current) {
|
|
267
|
+
try {
|
|
268
|
+
ws.close();
|
|
269
|
+
} catch {}
|
|
270
|
+
settle(false);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
pendingWsRef.current = null;
|
|
274
|
+
if (options.initOnOpen) try {
|
|
275
|
+
const initIncarnationId = incarnationIdRef.current;
|
|
276
|
+
const initControl = controlRef.current;
|
|
277
|
+
if (exactAttachRequiredRef.current && (!initIncarnationId || !initControl)) throw new Error("Interactive terminal identity is incomplete");
|
|
278
|
+
ws.send(JSON.stringify({
|
|
279
|
+
type: "init",
|
|
280
|
+
...initIncarnationId !== void 0 ? { incarnationId: initIncarnationId } : {},
|
|
281
|
+
...initControl !== void 0 ? { control: initControl } : {},
|
|
282
|
+
cols: colsRef.current,
|
|
283
|
+
rows: rowsRef.current
|
|
284
|
+
}));
|
|
285
|
+
geometryPendingRef.current = false;
|
|
286
|
+
} catch {
|
|
287
|
+
try {
|
|
288
|
+
ws.close();
|
|
289
|
+
} catch {}
|
|
290
|
+
settle(false);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (protocolFailureRef.current) return;
|
|
294
|
+
wsRef.current = ws;
|
|
295
|
+
};
|
|
296
|
+
ws.onmessage = (ev) => {
|
|
297
|
+
if (!mountedRef.current || !isCurrentSocket()) return;
|
|
298
|
+
const data = ev.data;
|
|
299
|
+
let text;
|
|
300
|
+
if (typeof data === "string") text = data;
|
|
301
|
+
else if (data instanceof ArrayBuffer) text = new TextDecoder().decode(data);
|
|
302
|
+
else if (ArrayBuffer.isView(data)) text = new TextDecoder().decode(data);
|
|
303
|
+
else {
|
|
304
|
+
data.text().then((t) => {
|
|
305
|
+
if (mountedRef.current && isCurrentSocket()) onDataRef.current(t);
|
|
306
|
+
}).catch(() => {});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (typeof data === "string") try {
|
|
310
|
+
const event = JSON.parse(text);
|
|
311
|
+
if (event !== null && typeof event === "object" && !Array.isArray(event)) {
|
|
312
|
+
const controlFrame = event;
|
|
313
|
+
switch (controlFrame.type) {
|
|
314
|
+
case "ready":
|
|
315
|
+
transportReadyRef.current = true;
|
|
316
|
+
setIsConnected(true);
|
|
317
|
+
setError(null);
|
|
318
|
+
retryCountRef.current = 0;
|
|
319
|
+
settle(true);
|
|
320
|
+
return;
|
|
321
|
+
case "error": {
|
|
322
|
+
const message = typeof controlFrame.message === "string" ? controlFrame.message : "Terminal WebSocket error";
|
|
323
|
+
if (!transportReadyRef.current) failProtocol(message);
|
|
324
|
+
else setError(message);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
case "exit":
|
|
328
|
+
transportReadyRef.current = false;
|
|
329
|
+
setIsConnected(false);
|
|
330
|
+
return;
|
|
331
|
+
case "rec":
|
|
332
|
+
case "rec.done": return;
|
|
333
|
+
default: {
|
|
334
|
+
const frameType = typeof controlFrame.type === "string" ? controlFrame.type : "<missing type>";
|
|
335
|
+
failProtocol(`Unknown terminal control frame: ${frameType}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch {}
|
|
341
|
+
if (text) onDataRef.current(text);
|
|
342
|
+
};
|
|
343
|
+
ws.onerror = () => {};
|
|
344
|
+
ws.onclose = () => {
|
|
345
|
+
if (pendingWsRef.current === ws) pendingWsRef.current = null;
|
|
346
|
+
const wasActive = wsRef.current === ws;
|
|
347
|
+
const wasReady = wasActive && transportReadyRef.current;
|
|
348
|
+
if (wasActive) {
|
|
349
|
+
wsRef.current = null;
|
|
350
|
+
transportReadyRef.current = false;
|
|
351
|
+
}
|
|
352
|
+
if (!opened) {
|
|
353
|
+
settle(false);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (!wasReady) {
|
|
357
|
+
settle(false);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (protocolFailureRef.current || !wasActive || !mountedRef.current) return;
|
|
361
|
+
setIsConnected(false);
|
|
362
|
+
if (sessionIdRef.current) retryTimerRef.current = setTimeout(() => {
|
|
363
|
+
if (mountedRef.current && sessionIdRef.current) {
|
|
364
|
+
if (shouldDeleteSessionRef.current) connectStreamRef.current?.(sessionIdRef.current);
|
|
365
|
+
else reconnectRef.current?.();
|
|
366
|
+
}
|
|
367
|
+
}, 1e3);
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
}, [apiUrl, rejectPendingInput]);
|
|
371
|
+
const patchGeometry = useCallback(async (sessionId, cols, rows) => {
|
|
372
|
+
try {
|
|
373
|
+
const res = await fetch(`${apiUrl}/terminals/${sessionId}`, {
|
|
374
|
+
method: "PATCH",
|
|
375
|
+
headers: {
|
|
376
|
+
Authorization: `Bearer ${tokenRef.current}`,
|
|
377
|
+
"Content-Type": "application/json"
|
|
378
|
+
},
|
|
379
|
+
credentials: "include",
|
|
380
|
+
body: JSON.stringify({
|
|
381
|
+
cols,
|
|
382
|
+
rows
|
|
383
|
+
})
|
|
384
|
+
});
|
|
385
|
+
if (!res.ok) console.error("Failed to resize terminal:", res.status);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error("Failed to resize terminal", err);
|
|
388
|
+
}
|
|
389
|
+
}, [apiUrl]);
|
|
390
|
+
const connectStream = useCallback(async (sessionId) => {
|
|
391
|
+
if (exactAttachRequiredRef.current) throw new Error("Interactive terminal requires an exact WebSocket attachment");
|
|
392
|
+
abortStream();
|
|
393
|
+
setError(null);
|
|
394
|
+
try {
|
|
395
|
+
const controller = new AbortController();
|
|
396
|
+
abortRef.current = controller;
|
|
397
|
+
const streamRes = await fetch(`${apiUrl}/terminals/${sessionId}/stream`, {
|
|
398
|
+
headers: { Authorization: `Bearer ${tokenRef.current}` },
|
|
399
|
+
credentials: "include",
|
|
400
|
+
signal: controller.signal
|
|
401
|
+
});
|
|
402
|
+
if (!streamRes.ok || !streamRes.body) {
|
|
403
|
+
const err = /* @__PURE__ */ new Error(`SSE stream failed: ${streamRes.status}`);
|
|
404
|
+
err.httpStatus = streamRes.status;
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
407
|
+
if (mountedRef.current) {
|
|
408
|
+
transportReadyRef.current = true;
|
|
409
|
+
setIsConnected(true);
|
|
410
|
+
setError(null);
|
|
411
|
+
retryCountRef.current = 0;
|
|
412
|
+
ensureDrainRunningRef.current?.();
|
|
413
|
+
if (geometryPendingRef.current) {
|
|
414
|
+
geometryPendingRef.current = false;
|
|
415
|
+
patchGeometry(sessionId, colsRef.current, rowsRef.current);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const reader = streamRes.body.getReader();
|
|
419
|
+
const decoder = new TextDecoder();
|
|
420
|
+
let buffer = "";
|
|
421
|
+
while (true) {
|
|
422
|
+
const { done, value } = await reader.read();
|
|
423
|
+
if (done) break;
|
|
424
|
+
buffer += decoder.decode(value, { stream: true });
|
|
425
|
+
const frames = buffer.split("\n\n");
|
|
426
|
+
buffer = frames.pop() ?? "";
|
|
427
|
+
for (const frame of frames) {
|
|
428
|
+
if (!frame.trim()) continue;
|
|
429
|
+
for (const line of frame.split("\n")) if (line.startsWith("data:")) {
|
|
430
|
+
const raw = line.slice(5).trim();
|
|
431
|
+
if (!raw) continue;
|
|
432
|
+
try {
|
|
433
|
+
const event = JSON.parse(raw);
|
|
434
|
+
if (event.type === "data.stdout" || event.type === "data.stderr") {
|
|
435
|
+
const text = event.properties?.text ?? "";
|
|
436
|
+
if (text && mountedRef.current) onDataRef.current(text);
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
if (mountedRef.current) onDataRef.current(raw);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (mountedRef.current) {
|
|
445
|
+
transportReadyRef.current = false;
|
|
446
|
+
setIsConnected(false);
|
|
447
|
+
retryTimerRef.current = setTimeout(() => {
|
|
448
|
+
if (mountedRef.current && sessionIdRef.current) connectStreamRef.current?.(sessionIdRef.current);
|
|
449
|
+
}, 1e3);
|
|
450
|
+
}
|
|
451
|
+
} catch (err) {
|
|
452
|
+
if (err.name === "AbortError") return;
|
|
453
|
+
if (mountedRef.current) {
|
|
454
|
+
const message = err instanceof Error ? err.message : "Stream connection failed";
|
|
455
|
+
setError(message);
|
|
456
|
+
transportReadyRef.current = false;
|
|
457
|
+
setIsConnected(false);
|
|
458
|
+
const httpStatus = err.httpStatus;
|
|
459
|
+
if (!(httpStatus !== void 0 && httpStatus >= 400 && httpStatus < 500) && retryCountRef.current < 8) {
|
|
460
|
+
const delay = Math.min(3e3 * Math.pow(2, retryCountRef.current), 3e4);
|
|
461
|
+
retryCountRef.current++;
|
|
462
|
+
retryTimerRef.current = setTimeout(() => {
|
|
463
|
+
if (mountedRef.current && sessionIdRef.current) connectStreamRef.current?.(sessionIdRef.current);
|
|
464
|
+
}, delay);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}, [
|
|
469
|
+
apiUrl,
|
|
470
|
+
abortStream,
|
|
471
|
+
patchGeometry
|
|
472
|
+
]);
|
|
473
|
+
onDataRef.current = onData;
|
|
474
|
+
tokenRef.current = token;
|
|
475
|
+
incarnationIdRef.current = incarnationId;
|
|
476
|
+
controlRef.current = control;
|
|
477
|
+
exactAttachRequiredRef.current = incarnationId !== void 0 || control !== void 0;
|
|
478
|
+
connectStreamRef.current = connectStream;
|
|
479
|
+
const exactIdentityKey = interactiveIdentityKey(incarnationId, control);
|
|
480
|
+
const connect = useCallback(async () => {
|
|
481
|
+
cleanup();
|
|
482
|
+
const myGen = ++connectGenRef.current;
|
|
483
|
+
protocolFailureRef.current = false;
|
|
484
|
+
retryCountRef.current = 0;
|
|
485
|
+
setError(null);
|
|
486
|
+
if (exactAttachRequiredRef.current && (!incarnationIdRef.current || !controlRef.current)) {
|
|
487
|
+
setError("Interactive terminal identity is incomplete");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const connectionId = providedConnectionId ?? createTerminalConnectionId();
|
|
492
|
+
if (!mountedRef.current) return;
|
|
493
|
+
sessionIdRef.current = connectionId;
|
|
494
|
+
shouldDeleteSessionRef.current = false;
|
|
495
|
+
transportReadyRef.current = false;
|
|
496
|
+
inputAbortRef.current = new AbortController();
|
|
497
|
+
const directWsOk = await connectWs(connectionId, { initOnOpen: true });
|
|
498
|
+
if (!mountedRef.current || connectGenRef.current !== myGen) return;
|
|
499
|
+
if (protocolFailureRef.current) return;
|
|
500
|
+
ensureDrainRunningRef.current?.();
|
|
501
|
+
if (directWsOk) return;
|
|
502
|
+
if (exactAttachRequiredRef.current) throw new Error("Interactive terminal WebSocket attach failed; exact identity was not accepted");
|
|
503
|
+
sessionIdRef.current = null;
|
|
504
|
+
transportReadyRef.current = false;
|
|
505
|
+
if (inputAbortRef.current) {
|
|
506
|
+
inputAbortRef.current.abort();
|
|
507
|
+
inputAbortRef.current = null;
|
|
508
|
+
}
|
|
509
|
+
const res = await fetch(`${apiUrl}/terminals`, {
|
|
510
|
+
method: "POST",
|
|
511
|
+
headers: {
|
|
512
|
+
Authorization: `Bearer ${tokenRef.current}`,
|
|
513
|
+
"Content-Type": "application/json"
|
|
514
|
+
},
|
|
515
|
+
credentials: "include",
|
|
516
|
+
body: JSON.stringify({
|
|
517
|
+
cols: colsRef.current,
|
|
518
|
+
rows: rowsRef.current
|
|
519
|
+
})
|
|
520
|
+
});
|
|
521
|
+
geometryPendingRef.current = false;
|
|
522
|
+
if (!res.ok) throw new Error(`Failed to create terminal: ${res.status}`);
|
|
523
|
+
const body = await res.json();
|
|
524
|
+
const sessionId = body.data?.sessionId ?? body.sessionId;
|
|
525
|
+
if (!sessionId) throw new Error("No sessionId in response");
|
|
526
|
+
if (!mountedRef.current) return;
|
|
527
|
+
sessionIdRef.current = sessionId;
|
|
528
|
+
shouldDeleteSessionRef.current = true;
|
|
529
|
+
transportReadyRef.current = false;
|
|
530
|
+
inputAbortRef.current = new AbortController();
|
|
531
|
+
const wsOk = await connectWs(sessionId, { initOnOpen: true });
|
|
532
|
+
if (!mountedRef.current || connectGenRef.current !== myGen) return;
|
|
533
|
+
if (protocolFailureRef.current) return;
|
|
534
|
+
ensureDrainRunningRef.current?.();
|
|
535
|
+
if (wsOk) return;
|
|
536
|
+
await connectStream(sessionId);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
if (err.name === "AbortError") return;
|
|
539
|
+
if (mountedRef.current) {
|
|
540
|
+
const message = err instanceof Error ? err.message : "Terminal connection failed";
|
|
541
|
+
setError(message);
|
|
542
|
+
transportReadyRef.current = false;
|
|
543
|
+
setIsConnected(false);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}, [
|
|
547
|
+
apiUrl,
|
|
548
|
+
providedConnectionId,
|
|
549
|
+
cleanup,
|
|
550
|
+
connectWs,
|
|
551
|
+
connectStream,
|
|
552
|
+
exactIdentityKey
|
|
553
|
+
]);
|
|
554
|
+
reconnectRef.current = connect;
|
|
555
|
+
const resizeTerminal = useCallback(async (cols, rows) => {
|
|
556
|
+
if (cols > 0) colsRef.current = cols;
|
|
557
|
+
if (rows > 0) rowsRef.current = rows;
|
|
558
|
+
const sid = sessionIdRef.current;
|
|
559
|
+
if (!sid || cols <= 0 || rows <= 0) return;
|
|
560
|
+
const ws = wsRef.current;
|
|
561
|
+
if (ws && ws.readyState === WebSocket.OPEN) try {
|
|
562
|
+
ws.send(JSON.stringify({
|
|
563
|
+
type: "resize",
|
|
564
|
+
cols,
|
|
565
|
+
rows
|
|
566
|
+
}));
|
|
567
|
+
return;
|
|
568
|
+
} catch (err) {
|
|
569
|
+
console.warn("Terminal resize over WS failed; falling back to HTTP", err);
|
|
570
|
+
}
|
|
571
|
+
if (pendingWsRef.current) {
|
|
572
|
+
geometryPendingRef.current = true;
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (exactAttachRequiredRef.current) throw new Error("Interactive terminal is not attached with its exact identity");
|
|
576
|
+
await patchGeometry(sid, cols, rows);
|
|
577
|
+
}, [apiUrl, patchGeometry]);
|
|
578
|
+
const drainInputQueue = useCallback(async () => {
|
|
579
|
+
while (pendingBatchRef.current.data.length > 0) {
|
|
580
|
+
const sid = sessionIdRef.current;
|
|
581
|
+
if (!sid || !transportReadyRef.current) return;
|
|
582
|
+
const batch = pendingBatchRef.current;
|
|
583
|
+
pendingBatchRef.current = createEmptyBatch();
|
|
584
|
+
const ws = wsRef.current;
|
|
585
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
586
|
+
try {
|
|
587
|
+
ws.send(JSON.stringify({
|
|
588
|
+
type: "input",
|
|
589
|
+
data: batch.data
|
|
590
|
+
}));
|
|
591
|
+
for (const w of batch.waiters) w.resolve();
|
|
592
|
+
} catch (err) {
|
|
593
|
+
for (const w of batch.waiters) w.reject(err);
|
|
594
|
+
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (exactAttachRequiredRef.current) {
|
|
598
|
+
for (const w of batch.waiters) w.reject(/* @__PURE__ */ new Error("Interactive terminal is not attached with its exact identity"));
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
const res = await fetch(`${apiUrl}/terminals/${sid}/input`, {
|
|
603
|
+
method: "POST",
|
|
604
|
+
headers: {
|
|
605
|
+
Authorization: `Bearer ${tokenRef.current}`,
|
|
606
|
+
"Content-Type": "application/json"
|
|
607
|
+
},
|
|
608
|
+
credentials: "include",
|
|
609
|
+
body: JSON.stringify({ data: batch.data }),
|
|
610
|
+
signal: inputAbortRef.current?.signal
|
|
611
|
+
});
|
|
612
|
+
if (!res.ok) {
|
|
613
|
+
const text = await res.text().catch(() => "");
|
|
614
|
+
throw new Error(text || `Input failed: ${res.status}`);
|
|
615
|
+
}
|
|
616
|
+
for (const w of batch.waiters) w.resolve();
|
|
617
|
+
} catch (err) {
|
|
618
|
+
const isAbort = err?.name === "AbortError";
|
|
619
|
+
const rejection = isAbort ? /* @__PURE__ */ new Error("Terminal session is not connected") : err;
|
|
620
|
+
if (!isAbort) console.error("Failed to send command", err);
|
|
621
|
+
for (const w of batch.waiters) w.reject(rejection);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}, [apiUrl]);
|
|
625
|
+
const ensureDrainRunning = useCallback(() => {
|
|
626
|
+
if (drainPromiseRef.current) return;
|
|
627
|
+
const run = () => drainInputQueue().finally(() => {
|
|
628
|
+
if (pendingBatchRef.current.data.length > 0 && sessionIdRef.current && transportReadyRef.current) drainPromiseRef.current = run();
|
|
629
|
+
else drainPromiseRef.current = null;
|
|
630
|
+
});
|
|
631
|
+
drainPromiseRef.current = run();
|
|
632
|
+
}, [drainInputQueue]);
|
|
633
|
+
ensureDrainRunningRef.current = ensureDrainRunning;
|
|
634
|
+
const sendCommand = useCallback((command) => {
|
|
635
|
+
return new Promise((resolve, reject) => {
|
|
636
|
+
if (command.length === 0) {
|
|
637
|
+
resolve();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (protocolFailureRef.current) {
|
|
641
|
+
reject(/* @__PURE__ */ new Error("Terminal protocol failure"));
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
pendingBatchRef.current.data += command;
|
|
645
|
+
pendingBatchRef.current.waiters.push({
|
|
646
|
+
resolve,
|
|
647
|
+
reject
|
|
648
|
+
});
|
|
649
|
+
ensureDrainRunning();
|
|
650
|
+
});
|
|
651
|
+
}, [ensureDrainRunning]);
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
mountedRef.current = true;
|
|
654
|
+
connect();
|
|
655
|
+
return () => {
|
|
656
|
+
mountedRef.current = false;
|
|
657
|
+
cleanup();
|
|
658
|
+
};
|
|
659
|
+
}, [connect, cleanup]);
|
|
660
|
+
return {
|
|
661
|
+
isConnected,
|
|
662
|
+
error,
|
|
663
|
+
sendCommand,
|
|
664
|
+
resizeTerminal,
|
|
665
|
+
reconnect: connect
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
//#endregion
|
|
669
|
+
export { usePtySession as t };
|