@opengeni/react 0.3.1 → 0.4.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/dist/index.d.ts +1035 -14
- package/dist/index.js +6867 -1884
- package/dist/index.js.map +1 -1
- package/package.json +65 -2
- package/src/client.ts +21 -0
- package/src/components/code-editor.tsx +398 -0
- package/src/components/desktop-viewer.tsx +647 -0
- package/src/components/diff-view.tsx +230 -0
- package/src/components/file-browser.tsx +838 -0
- package/src/components/message-timeline.tsx +70 -196
- package/src/components/pierre-diff.tsx +140 -0
- package/src/components/pierre-file.tsx +142 -0
- package/src/components/sandbox-files.tsx +509 -0
- package/src/components/sandbox-terminal.tsx +425 -0
- package/src/components/workspace-dock.tsx +247 -0
- package/src/hooks/use-desktop-stream.ts +214 -0
- package/src/hooks/use-sandbox-files.ts +670 -0
- package/src/hooks/use-sandbox-git.ts +105 -0
- package/src/hooks/use-sandbox-terminal.ts +226 -0
- package/src/hooks/use-session-capabilities.ts +415 -0
- package/src/hooks/use-terminal-stream.ts +207 -0
- package/src/index.ts +111 -2
- package/src/lib/cn.ts +20 -1
- package/src/lib/git-patch.ts +37 -0
- package/src/lib/use-theme-type.ts +40 -0
- package/src/lib/xterm-theme.ts +34 -0
- package/src/timeline/activity-rail.tsx +207 -0
- package/src/timeline/disclosure-context.tsx +34 -0
- package/src/timeline/index.ts +85 -0
- package/src/timeline/parsers.ts +248 -0
- package/src/{timeline.ts → timeline/projection.ts} +59 -134
- package/src/timeline/registry.ts +96 -0
- package/src/timeline/screenshot-lightbox.tsx +152 -0
- package/src/timeline/shared.tsx +481 -0
- package/src/timeline/tool-diff.tsx +91 -0
- package/src/timeline/tool-renderers.tsx +882 -0
- package/src/timeline/turn-summary.tsx +125 -0
- package/src/timeline/types.ts +131 -0
- package/src/types/external.d.ts +7 -0
- package/styles/index.css +72 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OpenGeniApiError,
|
|
3
|
+
applyUrlRotation,
|
|
4
|
+
type SessionCapabilities,
|
|
5
|
+
type SessionEvent,
|
|
6
|
+
type StreamUrlRotatedPayload,
|
|
7
|
+
} from "@opengeni/sdk";
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
9
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
10
|
+
|
|
11
|
+
export type SessionCapabilitiesState = "idle" | "negotiating" | "ready" | "cold" | "error";
|
|
12
|
+
|
|
13
|
+
export type UseSessionCapabilitiesOptions = ClientOverride & {
|
|
14
|
+
/**
|
|
15
|
+
* Live event log to fold `stream.url.rotated` from (usually
|
|
16
|
+
* `useSessionEvents().events`). When present the desktop socket stays fresh on
|
|
17
|
+
* a box rollover without a round-trip; stale-epoch rotations are dropped.
|
|
18
|
+
*/
|
|
19
|
+
events?: SessionEvent[] | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Whether to acquire a viewer holder for the desktop pixel plane. Requires the
|
|
22
|
+
* un-redacted acknowledgment to have been recorded (else the attach 409s and
|
|
23
|
+
* the hook surfaces the consent requirement). Default false: read-only
|
|
24
|
+
* negotiation (no holder, no warm) — terminal/files/git work without it.
|
|
25
|
+
*/
|
|
26
|
+
attachDesktop?: boolean | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Whether to acquire a viewer holder to warm the box for the REAL interactive
|
|
29
|
+
* terminal (the ttyd pty-ws plane). Symmetric with `attachDesktop` and shares
|
|
30
|
+
* the SAME viewer attach (one warm box serves both planes), but needs NO
|
|
31
|
+
* un-redacted acknowledgment — a shell is interactive by nature, and the gate
|
|
32
|
+
* is the scoped tunnel URL + stream token. Default false: the terminal stays on
|
|
33
|
+
* the read-only Channel-A firehose until the user opens/focuses it. The attach
|
|
34
|
+
* folds the minted `pty-ws` url+token into the `Terminal` cell.
|
|
35
|
+
*/
|
|
36
|
+
attachTerminal?: boolean | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Whether to acquire a viewer holder purely to KEEP THE BOX WARM while the
|
|
39
|
+
* structured Files surface is open. Shares the SAME viewer attach as the
|
|
40
|
+
* desktop/terminal (one warm box, one holder) and — like the terminal — needs
|
|
41
|
+
* NO un-redacted acknowledgment: listing/reading/writing files is the ordinary
|
|
42
|
+
* Channel-A control plane, not the pixel plane. Default false: the Files tab
|
|
43
|
+
* negotiates read-only and each op pays the cold-box resume (~5s). When true,
|
|
44
|
+
* the holder warms the box once and heartbeats it, so subsequent fs ops are
|
|
45
|
+
* ~100ms instead of re-resuming the box on every list/write. It folds NO live
|
|
46
|
+
* URL (files ride the stateless HTTP plane) — it only refcounts liveness.
|
|
47
|
+
*/
|
|
48
|
+
attachFiles?: boolean | undefined;
|
|
49
|
+
/** Hold off negotiating (e.g. the workbench panel is collapsed). Default true. */
|
|
50
|
+
enabled?: boolean | undefined;
|
|
51
|
+
/** Poll cadence (ms) while the lease is cold/warming. Default 1500. */
|
|
52
|
+
warmingPollMs?: number | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Give up waiting for `warm` after this long while polling (ms) and surface a
|
|
55
|
+
* stalled error with a manual `renegotiate`. Default 30000 (must agree with
|
|
56
|
+
* the lease warming TTL — I15). 0 disables the deadline.
|
|
57
|
+
*/
|
|
58
|
+
warmingDeadlineMs?: number | undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type UseSessionCapabilitiesResult = {
|
|
62
|
+
/** The negotiated capability doc — the single source of UI truth. */
|
|
63
|
+
capabilities: SessionCapabilities | null;
|
|
64
|
+
state: SessionCapabilitiesState;
|
|
65
|
+
error: Error | null;
|
|
66
|
+
/**
|
|
67
|
+
* 409 from the desktop attach: the un-redacted (or shared) plane needs explicit
|
|
68
|
+
* acknowledgment before a viewer holder is granted. Drives the consent prompt.
|
|
69
|
+
*/
|
|
70
|
+
acknowledgmentRequired: "unredacted" | "shared" | null;
|
|
71
|
+
/** 429 from the desktop attach: the per-session viewer cap is reached. */
|
|
72
|
+
viewerCapReached: boolean;
|
|
73
|
+
/** The viewer holder id minted on a desktop attach (for detach/heartbeat). */
|
|
74
|
+
viewerId: string | null;
|
|
75
|
+
/** Force a re-negotiation (after acknowledging, a resolution change, etc.). */
|
|
76
|
+
renegotiate: () => void;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a desktop attach (POST /viewers) is worth attempting for this cell.
|
|
81
|
+
* The desktop is FEASIBLE — and thus worth warming — when it already has a live
|
|
82
|
+
* transport, OR when the only thing missing is a warm box (a cold/un-provisioned
|
|
83
|
+
* lease). The handshake never warms a box, so a feasible-but-cold cell reports
|
|
84
|
+
* transport:null with a transient reason; the viewer attach is exactly what warms
|
|
85
|
+
* it. A genuinely-unsupported desktop (backend/os/policy/headless) is never
|
|
86
|
+
* attachable — attaching would 409/403/no-op without ever producing a stream.
|
|
87
|
+
*/
|
|
88
|
+
function desktopAttachable(cell: SessionCapabilities["DesktopStream"]): boolean {
|
|
89
|
+
if (cell.transport !== null) return true;
|
|
90
|
+
// transport === null: attach only when the reason is a transient cold state.
|
|
91
|
+
return cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether warming the box for the interactive terminal (pty-ws) is worth it.
|
|
96
|
+
* The Terminal cell is ALWAYS feasible when the backend advertises a transport at
|
|
97
|
+
* all (`sse-events` on a cold box, `pty-ws` once warm) — only a genuinely
|
|
98
|
+
* terminal-less backend reports transport:null with a hard reason. The attach is
|
|
99
|
+
* what flips `sse-events` → `pty-ws` by warming the box and minting the ttyd
|
|
100
|
+
* tunnel URL, so we attach whenever the terminal is not hard-unavailable.
|
|
101
|
+
*/
|
|
102
|
+
function terminalAttachable(cell: SessionCapabilities["Terminal"]): boolean {
|
|
103
|
+
if (cell.transport === null) {
|
|
104
|
+
// No terminal at all unless the only blocker is a transient cold state.
|
|
105
|
+
return cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null;
|
|
106
|
+
}
|
|
107
|
+
// Already pty-ws (warm) is fine to (re)attach; sse-events is the cold state the
|
|
108
|
+
// attach upgrades. Either way it's attachable.
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Read `stream.url.rotated` payloads off the live event log, newest last. */
|
|
113
|
+
function rotationsFrom(events: SessionEvent[]): StreamUrlRotatedPayload[] {
|
|
114
|
+
const out: StreamUrlRotatedPayload[] = [];
|
|
115
|
+
for (const event of events) {
|
|
116
|
+
if (event.type === "stream.url.rotated" && event.payload && typeof event.payload === "object") {
|
|
117
|
+
out.push(event.payload as StreamUrlRotatedPayload);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The capability-negotiation hook. Discovers what THIS session+backend+OS
|
|
125
|
+
* supports (FileSystem/Terminal/Git always-ish; DesktopStream/Recording
|
|
126
|
+
* sometimes), drives capability-gated rendering, and — when `attachDesktop` —
|
|
127
|
+
* holds a viewer lease + heartbeats it so the box stays warm while watched.
|
|
128
|
+
*
|
|
129
|
+
* Degradation is a value, never a crash: an unsupported surface comes back
|
|
130
|
+
* `available:false`/`transport:null` + a `reason`; the components render the
|
|
131
|
+
* reason-aware empty state. 409 (consent) and 429 (viewer cap) are surfaced as
|
|
132
|
+
* typed signals, not thrown.
|
|
133
|
+
*/
|
|
134
|
+
export function useSessionCapabilities(
|
|
135
|
+
sessionId: string | null | undefined,
|
|
136
|
+
options: UseSessionCapabilitiesOptions = {},
|
|
137
|
+
): UseSessionCapabilitiesResult {
|
|
138
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
139
|
+
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
140
|
+
const attachDesktop = options.attachDesktop ?? false;
|
|
141
|
+
const attachTerminal = options.attachTerminal ?? false;
|
|
142
|
+
const attachFiles = options.attachFiles ?? false;
|
|
143
|
+
const warmingPollMs = options.warmingPollMs ?? 1500;
|
|
144
|
+
const warmingDeadlineMs = options.warmingDeadlineMs ?? 30_000;
|
|
145
|
+
|
|
146
|
+
const [capabilities, setCapabilities] = useState<SessionCapabilities | null>(null);
|
|
147
|
+
const [state, setState] = useState<SessionCapabilitiesState>("idle");
|
|
148
|
+
const [error, setError] = useState<Error | null>(null);
|
|
149
|
+
const [acknowledgmentRequired, setAcknowledgmentRequired] = useState<"unredacted" | "shared" | null>(null);
|
|
150
|
+
const [viewerCapReached, setViewerCapReached] = useState(false);
|
|
151
|
+
const [viewerId, setViewerId] = useState<string | null>(null);
|
|
152
|
+
// Bumped to force a fresh negotiation cycle.
|
|
153
|
+
const [nonce, setNonce] = useState(0);
|
|
154
|
+
|
|
155
|
+
// The epoch the client has settled on — used to fence stale rotations folded
|
|
156
|
+
// from the event log, and echoed on heartbeats.
|
|
157
|
+
const epochRef = useRef(0);
|
|
158
|
+
const viewerIdRef = useRef<string | null>(null);
|
|
159
|
+
|
|
160
|
+
const renegotiate = useCallback(() => {
|
|
161
|
+
setNonce((n) => n + 1);
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
// ── Negotiation + viewer lifecycle ──────────────────────────────────────────
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!enabled || !sessionId) {
|
|
167
|
+
setState("idle");
|
|
168
|
+
setCapabilities(null);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let cancelled = false;
|
|
172
|
+
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
173
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
174
|
+
let localViewerId: string | null = null;
|
|
175
|
+
const startedAt = Date.now();
|
|
176
|
+
|
|
177
|
+
const clearTimers = () => {
|
|
178
|
+
if (pollTimer !== null) clearTimeout(pollTimer);
|
|
179
|
+
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
|
|
180
|
+
pollTimer = null;
|
|
181
|
+
heartbeatTimer = null;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const settle = (caps: SessionCapabilities) => {
|
|
185
|
+
if (cancelled) return;
|
|
186
|
+
epochRef.current = caps.leaseEpoch;
|
|
187
|
+
setCapabilities(caps);
|
|
188
|
+
setError(null);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const startHeartbeat = (caps: SessionCapabilities) => {
|
|
192
|
+
if (!localViewerId || heartbeatTimer !== null) return;
|
|
193
|
+
const intervalMs = caps.viewerHeartbeatIntervalMs > 0 ? caps.viewerHeartbeatIntervalMs : 30_000;
|
|
194
|
+
heartbeatTimer = setInterval(() => {
|
|
195
|
+
if (cancelled || !localViewerId) return;
|
|
196
|
+
void client
|
|
197
|
+
.heartbeatViewer(workspaceId, sessionId, localViewerId, { leaseEpoch: epochRef.current })
|
|
198
|
+
.then((res) => {
|
|
199
|
+
// alive:false ⇒ the holder was reaped or the epoch moved under us;
|
|
200
|
+
// re-negotiate to re-acquire against the new owner.
|
|
201
|
+
if (!res.alive && !cancelled) {
|
|
202
|
+
renegotiate();
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
.catch((cause) => {
|
|
206
|
+
if (cancelled) return;
|
|
207
|
+
if (cause instanceof OpenGeniApiError && (cause.status === 409 || cause.status === 410)) {
|
|
208
|
+
renegotiate();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}, intervalMs);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const pollUntilWarm = () => {
|
|
215
|
+
pollTimer = setTimeout(() => {
|
|
216
|
+
if (cancelled) return;
|
|
217
|
+
void client
|
|
218
|
+
.getStreamCapabilities(workspaceId, sessionId)
|
|
219
|
+
.then((caps) => {
|
|
220
|
+
if (cancelled) return;
|
|
221
|
+
settle(caps);
|
|
222
|
+
if (caps.liveness === "warm" || caps.liveness === "draining") {
|
|
223
|
+
setState("ready");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (warmingDeadlineMs > 0 && Date.now() - startedAt > warmingDeadlineMs) {
|
|
227
|
+
setState("error");
|
|
228
|
+
setError(new Error("sandbox did not warm in time — retry to re-negotiate"));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
pollUntilWarm();
|
|
232
|
+
})
|
|
233
|
+
.catch((cause) => {
|
|
234
|
+
if (!cancelled) {
|
|
235
|
+
setState("error");
|
|
236
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}, warmingPollMs);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
void (async () => {
|
|
243
|
+
setState("negotiating");
|
|
244
|
+
setAcknowledgmentRequired(null);
|
|
245
|
+
setViewerCapReached(false);
|
|
246
|
+
try {
|
|
247
|
+
const caps = await client.getStreamCapabilities(workspaceId, sessionId);
|
|
248
|
+
if (cancelled) return;
|
|
249
|
+
settle(caps);
|
|
250
|
+
|
|
251
|
+
// Optional viewer attach: acquire a viewer holder (warms a cold box). ONE
|
|
252
|
+
// attach serves BOTH live planes — the desktop pixel stream AND the
|
|
253
|
+
// interactive terminal (ttyd pty-ws) ride the same warm box and the same
|
|
254
|
+
// holder; the response folds the minted address for each requested plane.
|
|
255
|
+
// The consent gate (409) and the viewer cap (429) surface as typed signals
|
|
256
|
+
// rather than throwing — the tabs degrade gracefully.
|
|
257
|
+
//
|
|
258
|
+
// KEY: the handshake (getStreamCapabilities) NEVER spins up a cold box, so
|
|
259
|
+
// on a cold/warming lease the desktop cell comes back transport:null and
|
|
260
|
+
// the terminal cell comes back `sse-events` (read-only firehose) with a
|
|
261
|
+
// transient reason (`lease_cold`/`not_provisioned`). Gating the attach on
|
|
262
|
+
// `transport` therefore dead-ends both surfaces (forever "Starting…"): they
|
|
263
|
+
// never warm because they never attach, and never attach because they
|
|
264
|
+
// aren't warm. We attach whenever a REQUESTED plane is FEASIBLE — transport
|
|
265
|
+
// already live OR cold-but-feasible — and let POST /viewers warm the box and
|
|
266
|
+
// mint the URLs. Only a genuinely-unsupported reason suppresses the attach.
|
|
267
|
+
const wantDesktopAttach = attachDesktop && desktopAttachable(caps.DesktopStream);
|
|
268
|
+
const wantTerminalAttach = attachTerminal && terminalAttachable(caps.Terminal);
|
|
269
|
+
// The Files surface warms the box for fast Channel-A ops. It folds no live
|
|
270
|
+
// URL (files are stateless HTTP) — the attach is purely a liveness refcount,
|
|
271
|
+
// so it's wanted whenever the FileSystem capability is advertised at all.
|
|
272
|
+
const wantFilesAttach = attachFiles && caps.FileSystem.available;
|
|
273
|
+
if (wantDesktopAttach || wantTerminalAttach || wantFilesAttach) {
|
|
274
|
+
try {
|
|
275
|
+
// Declare WHICH plane we're attaching for. `desktop:true` opts into the
|
|
276
|
+
// un-redacted pixel plane (which carries the consent gate); a
|
|
277
|
+
// terminal-only attach (`desktop:false`) warms the box + mints the
|
|
278
|
+
// pty-ws terminal cell WITHOUT tripping the desktop consent 409.
|
|
279
|
+
const holder = await client.attachViewer(workspaceId, sessionId, { desktop: wantDesktopAttach });
|
|
280
|
+
if (cancelled) return;
|
|
281
|
+
localViewerId = holder.viewerId;
|
|
282
|
+
viewerIdRef.current = holder.viewerId;
|
|
283
|
+
setViewerId(holder.viewerId);
|
|
284
|
+
epochRef.current = holder.leaseEpoch;
|
|
285
|
+
// Fold the freshly-minted live address(es) into the doc the components
|
|
286
|
+
// read. Desktop fields fold only when a desktop attach was wanted;
|
|
287
|
+
// terminal fields fold the minted ttyd pty-ws url+token when present.
|
|
288
|
+
setCapabilities((prev) =>
|
|
289
|
+
prev
|
|
290
|
+
? {
|
|
291
|
+
...prev,
|
|
292
|
+
liveness: holder.liveness,
|
|
293
|
+
leaseEpoch: holder.leaseEpoch,
|
|
294
|
+
viewerHeartbeatIntervalMs: holder.viewerHeartbeatIntervalMs,
|
|
295
|
+
DesktopStream: wantDesktopAttach
|
|
296
|
+
? {
|
|
297
|
+
...prev.DesktopStream,
|
|
298
|
+
transport: holder.transport ?? prev.DesktopStream.transport,
|
|
299
|
+
client: holder.client ?? prev.DesktopStream.client,
|
|
300
|
+
url: holder.dataPlaneUrl ?? prev.DesktopStream.url,
|
|
301
|
+
token: holder.streamToken ?? prev.DesktopStream.token,
|
|
302
|
+
expiresAt: holder.streamExpiresAt ?? prev.DesktopStream.expiresAt,
|
|
303
|
+
resolution: holder.resolution ?? prev.DesktopStream.resolution,
|
|
304
|
+
}
|
|
305
|
+
: prev.DesktopStream,
|
|
306
|
+
Terminal:
|
|
307
|
+
holder.terminalUrl && holder.terminalTransport
|
|
308
|
+
? {
|
|
309
|
+
...prev.Terminal,
|
|
310
|
+
transport: holder.terminalTransport,
|
|
311
|
+
url: holder.terminalUrl,
|
|
312
|
+
token: holder.terminalToken ?? prev.Terminal.token,
|
|
313
|
+
// A live pty-ws means the box is pty-capable for real.
|
|
314
|
+
ptyCapable: true,
|
|
315
|
+
reason: null,
|
|
316
|
+
}
|
|
317
|
+
: prev.Terminal,
|
|
318
|
+
}
|
|
319
|
+
: prev,
|
|
320
|
+
);
|
|
321
|
+
} catch (cause) {
|
|
322
|
+
if (cancelled) return;
|
|
323
|
+
if (cause instanceof OpenGeniApiError) {
|
|
324
|
+
if (cause.status === 409) {
|
|
325
|
+
// The un-redacted/shared consent gate is a DESKTOP requirement. A
|
|
326
|
+
// terminal-only warm attach needs no consent, so a 409 there is not
|
|
327
|
+
// a consent prompt — the terminal just stays on the read-only
|
|
328
|
+
// firehose. Only raise the consent requirement when the desktop was
|
|
329
|
+
// the (or a) reason we attached.
|
|
330
|
+
if (wantDesktopAttach) {
|
|
331
|
+
setAcknowledgmentRequired(
|
|
332
|
+
cause.message.includes("shared_acknowledgment") ? "shared" : "unredacted",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
} else if (cause.status === 429) {
|
|
336
|
+
setViewerCapReached(true);
|
|
337
|
+
} else if (cause.status === 403) {
|
|
338
|
+
setState("error");
|
|
339
|
+
setError(cause);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// 409/429 are recoverable: structured surfaces still negotiated;
|
|
343
|
+
// keep going to set ready/cold below.
|
|
344
|
+
} else {
|
|
345
|
+
throw cause;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (caps.liveness === "warm" || caps.liveness === "draining" || localViewerId) {
|
|
351
|
+
setState("ready");
|
|
352
|
+
startHeartbeat(caps);
|
|
353
|
+
} else {
|
|
354
|
+
setState("cold");
|
|
355
|
+
pollUntilWarm();
|
|
356
|
+
}
|
|
357
|
+
} catch (cause) {
|
|
358
|
+
if (cancelled) return;
|
|
359
|
+
if (cause instanceof OpenGeniApiError && cause.status === 403) {
|
|
360
|
+
setState("error");
|
|
361
|
+
setError(new Error("not permitted to view this session's sandbox"));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
setState("error");
|
|
365
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
368
|
+
|
|
369
|
+
return () => {
|
|
370
|
+
cancelled = true;
|
|
371
|
+
clearTimers();
|
|
372
|
+
// Fire-and-forget detach (idempotent delete-my-row). Capture the id so a
|
|
373
|
+
// re-render/unmount race still releases the right holder.
|
|
374
|
+
const releaseId = localViewerId ?? viewerIdRef.current;
|
|
375
|
+
if (releaseId) {
|
|
376
|
+
void client.detachViewer(workspaceId, sessionId, releaseId).catch(() => {});
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
380
|
+
}, [client, workspaceId, sessionId, enabled, attachDesktop, attachTerminal, attachFiles, warmingPollMs, warmingDeadlineMs, nonce]);
|
|
381
|
+
|
|
382
|
+
// ── Fold stream.url.rotated from the live event log (no round trip) ──────────
|
|
383
|
+
const events = options.events;
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (!events || events.length === 0) return;
|
|
386
|
+
setCapabilities((prev) => {
|
|
387
|
+
if (!prev || !prev.DesktopStream.url) return prev;
|
|
388
|
+
let next = prev.DesktopStream;
|
|
389
|
+
let changed = false;
|
|
390
|
+
for (const rotation of rotationsFrom(events)) {
|
|
391
|
+
// Only this viewer's rotations (others' are filtered by viewerId when set).
|
|
392
|
+
if (rotation.viewerId && viewerIdRef.current && rotation.viewerId !== viewerIdRef.current) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const applied = applyUrlRotation(next, rotation, epochRef.current);
|
|
396
|
+
if (applied) {
|
|
397
|
+
next = { ...next, url: applied.url, token: applied.token, expiresAt: applied.expiresAt };
|
|
398
|
+
epochRef.current = Math.max(epochRef.current, rotation.leaseEpoch);
|
|
399
|
+
changed = true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return changed ? { ...prev, DesktopStream: next, leaseEpoch: epochRef.current } : prev;
|
|
403
|
+
});
|
|
404
|
+
}, [events]);
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
capabilities,
|
|
408
|
+
state,
|
|
409
|
+
error,
|
|
410
|
+
acknowledgmentRequired,
|
|
411
|
+
viewerCapReached,
|
|
412
|
+
viewerId,
|
|
413
|
+
renegotiate,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TTYD_SUBPROTOCOL,
|
|
3
|
+
TerminalCapability,
|
|
4
|
+
terminalSocketUrl,
|
|
5
|
+
ttydAuthFrame,
|
|
6
|
+
ttydInputFrame,
|
|
7
|
+
ttydResizeFrame,
|
|
8
|
+
TtydServerCommand,
|
|
9
|
+
} from "@opengeni/sdk";
|
|
10
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
|
|
12
|
+
/** The ttyd connection lifecycle as surfaced to the component. */
|
|
13
|
+
export type TerminalStreamStatus = "connecting" | "open" | "closed" | "error";
|
|
14
|
+
|
|
15
|
+
export type UseTerminalStreamOptions = {
|
|
16
|
+
/** The Terminal cell of the negotiated capabilities (`capabilities.Terminal`).
|
|
17
|
+
* The stream connects ONLY when `transport === "pty-ws"` and `url` is set; on a
|
|
18
|
+
* cold box (`transport === "sse-events"` / no url) it stays idle and the caller
|
|
19
|
+
* falls back to the Channel-A read-only firehose. */
|
|
20
|
+
capability: Pick<TerminalCapability, "transport" | "url" | "token"> | null;
|
|
21
|
+
/** Called for each OUTPUT payload from ttyd (write verbatim into xterm). */
|
|
22
|
+
onOutput?: ((data: string) => void) | undefined;
|
|
23
|
+
/** Called when ttyd sends a SET_WINDOW_TITLE frame. */
|
|
24
|
+
onTitle?: ((title: string) => void) | undefined;
|
|
25
|
+
/** Initial PTY size to seed the ttyd auth frame + first resize. */
|
|
26
|
+
initialCols?: number | undefined;
|
|
27
|
+
initialRows?: number | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type UseTerminalStreamResult = {
|
|
31
|
+
/** True once the ttyd socket is open (and the auth frame has been sent). */
|
|
32
|
+
connected: boolean;
|
|
33
|
+
status: TerminalStreamStatus;
|
|
34
|
+
/** Pipe a keystroke/paste to the PTY stdin. No-op until the socket is open. */
|
|
35
|
+
write: (data: string) => void;
|
|
36
|
+
/** Tell ttyd the PTY window changed size (on xterm fit/resize). */
|
|
37
|
+
resize: (cols: number, rows: number) => void;
|
|
38
|
+
/** Tear the socket down (the effect also tears down on unmount / url change). */
|
|
39
|
+
disconnect: () => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Decode an inbound ttyd frame's payload (everything after the 1-char command).
|
|
43
|
+
* ttyd may send either a text frame (string) or a binary frame (ArrayBuffer);
|
|
44
|
+
* for binary we slice off the first byte (the command) and utf-8 decode the rest. */
|
|
45
|
+
function decodeFrame(data: string | ArrayBuffer): { command: string; payload: string } {
|
|
46
|
+
if (typeof data === "string") {
|
|
47
|
+
return { command: data.charAt(0), payload: data.slice(1) };
|
|
48
|
+
}
|
|
49
|
+
const bytes = new Uint8Array(data);
|
|
50
|
+
const command = bytes.length > 0 ? String.fromCharCode(bytes[0]!) : "";
|
|
51
|
+
const payload = bytes.length > 1 ? new TextDecoder().decode(bytes.subarray(1)) : "";
|
|
52
|
+
return { command, payload };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drive a ttyd PTY-over-websocket connection from a `pty-ws` Terminal capability,
|
|
57
|
+
* symmetric with `use-desktop-stream` (the noVNC-over-tunnel hook). The scoped
|
|
58
|
+
* stream token is already embedded in the minted tunnel `url`; the WebSocket is
|
|
59
|
+
* opened with the REQUIRED ttyd subprotocol "tty".
|
|
60
|
+
*
|
|
61
|
+
* ttyd wire protocol (see `@opengeni/sdk/terminal`):
|
|
62
|
+
* - first frame: `JSON.stringify({ AuthToken: "" })` (+ optional columns/rows).
|
|
63
|
+
* - client→server: INPUT = "0"+data ; RESIZE = "1"+JSON({columns,rows}).
|
|
64
|
+
* - server→client: "0" = OUTPUT (→ xterm) ; "1" = SET_WINDOW_TITLE ;
|
|
65
|
+
* "2" = SET_PREFERENCES (ignored). Binary frames are decoded the same way.
|
|
66
|
+
*
|
|
67
|
+
* On a `url`/`token` rotation (a box rollover folds a fresh address into the cell)
|
|
68
|
+
* the effect re-runs: the old socket closes and a fresh one connects — a brief
|
|
69
|
+
* terminal blink, acceptable on rollover (mirrors the desktop's RFB hot-swap).
|
|
70
|
+
* SSR-safe: the socket open lives in `useEffect`, so a server render is a no-op.
|
|
71
|
+
*/
|
|
72
|
+
export function useTerminalStream(options: UseTerminalStreamOptions): UseTerminalStreamResult {
|
|
73
|
+
const { capability, onOutput, onTitle, initialCols, initialRows } = options;
|
|
74
|
+
const [status, setStatus] = useState<TerminalStreamStatus>("closed");
|
|
75
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
76
|
+
// Latest size, so a resize() before the socket opens is replayed on open, and a
|
|
77
|
+
// reconnect seeds the right geometry.
|
|
78
|
+
const sizeRef = useRef<{ cols: number; rows: number }>({
|
|
79
|
+
cols: initialCols ?? 80,
|
|
80
|
+
rows: initialRows ?? 24,
|
|
81
|
+
});
|
|
82
|
+
// Keep the callbacks current without re-running the connect effect on every
|
|
83
|
+
// render (the parent passes fresh closures each time).
|
|
84
|
+
const onOutputRef = useRef(onOutput);
|
|
85
|
+
const onTitleRef = useRef(onTitle);
|
|
86
|
+
onOutputRef.current = onOutput;
|
|
87
|
+
onTitleRef.current = onTitle;
|
|
88
|
+
|
|
89
|
+
const transport = capability?.transport ?? null;
|
|
90
|
+
const url = capability?.url ?? null;
|
|
91
|
+
const token = capability?.token ?? null;
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
// SSR / no WebSocket / not a live pty-ws cell: stay closed; the caller falls
|
|
95
|
+
// back to the Channel-A read-only firehose.
|
|
96
|
+
if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
|
|
97
|
+
if (transport !== "pty-ws" || !url) {
|
|
98
|
+
setStatus("closed");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let disposed = false;
|
|
103
|
+
let socket: WebSocket;
|
|
104
|
+
setStatus("connecting");
|
|
105
|
+
try {
|
|
106
|
+
// The ttyd "tty" subprotocol is REQUIRED — ttyd rejects a handshake without
|
|
107
|
+
// it. The scoped token is already in the tunnel `url`.
|
|
108
|
+
socket = new WebSocket(terminalSocketUrl({ url }), TTYD_SUBPROTOCOL);
|
|
109
|
+
} catch {
|
|
110
|
+
setStatus("error");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
socket.binaryType = "arraybuffer";
|
|
114
|
+
wsRef.current = socket;
|
|
115
|
+
|
|
116
|
+
socket.onopen = () => {
|
|
117
|
+
if (disposed) return;
|
|
118
|
+
// ttyd's required first frame: the auth message (empty token — the gate is
|
|
119
|
+
// the tunnel url + scoped stream token, not a ttyd -c credential), seeded
|
|
120
|
+
// with the current PTY geometry. Then an explicit resize to be safe.
|
|
121
|
+
try {
|
|
122
|
+
socket.send(ttydAuthFrame({ columns: sizeRef.current.cols, rows: sizeRef.current.rows }));
|
|
123
|
+
socket.send(ttydResizeFrame(sizeRef.current.cols, sizeRef.current.rows));
|
|
124
|
+
} catch {
|
|
125
|
+
// a closed socket between open and send — onclose handles state.
|
|
126
|
+
}
|
|
127
|
+
setStatus("open");
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
socket.onmessage = (ev: MessageEvent) => {
|
|
131
|
+
if (disposed) return;
|
|
132
|
+
const { command, payload } = decodeFrame(ev.data as string | ArrayBuffer);
|
|
133
|
+
switch (command) {
|
|
134
|
+
case TtydServerCommand.OUTPUT:
|
|
135
|
+
onOutputRef.current?.(payload);
|
|
136
|
+
break;
|
|
137
|
+
case TtydServerCommand.SET_WINDOW_TITLE:
|
|
138
|
+
onTitleRef.current?.(payload);
|
|
139
|
+
break;
|
|
140
|
+
// SET_PREFERENCES ("2") and anything else: ignored.
|
|
141
|
+
default:
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
socket.onerror = () => {
|
|
147
|
+
if (!disposed) setStatus("error");
|
|
148
|
+
};
|
|
149
|
+
socket.onclose = () => {
|
|
150
|
+
if (!disposed) setStatus("closed");
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
disposed = true;
|
|
155
|
+
wsRef.current = null;
|
|
156
|
+
// Drop handlers so an in-flight close/error doesn't mutate state post-unmount.
|
|
157
|
+
socket.onopen = null;
|
|
158
|
+
socket.onmessage = null;
|
|
159
|
+
socket.onerror = null;
|
|
160
|
+
socket.onclose = null;
|
|
161
|
+
try {
|
|
162
|
+
socket.close();
|
|
163
|
+
} catch {
|
|
164
|
+
// ignore teardown errors
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
// A url/token change (rotation) re-runs this effect → close old, open new.
|
|
168
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
169
|
+
}, [transport, url, token]);
|
|
170
|
+
|
|
171
|
+
return useMemo<UseTerminalStreamResult>(() => {
|
|
172
|
+
const write = (data: string) => {
|
|
173
|
+
const ws = wsRef.current;
|
|
174
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
175
|
+
try {
|
|
176
|
+
ws.send(ttydInputFrame(data));
|
|
177
|
+
} catch {
|
|
178
|
+
// socket raced closed — the reconnect effect will re-establish.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const resize = (cols: number, rows: number) => {
|
|
183
|
+
if (cols <= 0 || rows <= 0) return;
|
|
184
|
+
sizeRef.current = { cols, rows };
|
|
185
|
+
const ws = wsRef.current;
|
|
186
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
187
|
+
try {
|
|
188
|
+
ws.send(ttydResizeFrame(cols, rows));
|
|
189
|
+
} catch {
|
|
190
|
+
// socket raced closed — geometry is replayed on the next open.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const disconnect = () => {
|
|
195
|
+
const ws = wsRef.current;
|
|
196
|
+
wsRef.current = null;
|
|
197
|
+
if (ws) {
|
|
198
|
+
try {
|
|
199
|
+
ws.close();
|
|
200
|
+
} catch {
|
|
201
|
+
// ignore
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
return { connected: status === "open", status, write, resize, disconnect };
|
|
206
|
+
}, [status]);
|
|
207
|
+
}
|