@anvia/react-ui 1.1.9 → 1.2.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 +4 -0
- package/dist/chunk-5FZKTDZR.js +201 -0
- package/dist/chunk-5FZKTDZR.js.map +1 -0
- package/dist/sandbox-desktop/index.d.ts +122 -0
- package/dist/sandbox-desktop/index.js +356 -0
- package/dist/sandbox-desktop/index.js.map +1 -0
- package/dist/sandbox-terminal/index.d.ts +68 -0
- package/dist/sandbox-terminal/index.js +216 -0
- package/dist/sandbox-terminal/index.js.map +1 -0
- package/dist/ticket-pexI1zIa.d.ts +33 -0
- package/package.json +27 -4
package/README.md
CHANGED
|
@@ -67,6 +67,10 @@ semantic attributes and behavior; it has no package stylesheet.
|
|
|
67
67
|
- Images, selection toolbars, and optional streaming presentation smoothing.
|
|
68
68
|
- Graph exploration primitives through `@anvia/react-ui/graph-explorer`; install `@anvia/graph`
|
|
69
69
|
when using that integration.
|
|
70
|
+
- A remote sandbox terminal through `@anvia/react-ui/sandbox-terminal`; install `@xterm/xterm` and
|
|
71
|
+
`@xterm/addon-fit` when using it.
|
|
72
|
+
- A remote sandbox desktop viewer with take-over control through
|
|
73
|
+
`@anvia/react-ui/sandbox-desktop`; install `@novnc/novnc` when using it.
|
|
70
74
|
|
|
71
75
|
## Learn more
|
|
72
76
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// src/relay/connection.ts
|
|
2
|
+
var ticketSubprotocolPrefix = "anvia.ticket.";
|
|
3
|
+
var defaultInitialDelayMs = 500;
|
|
4
|
+
var defaultMaxDelayMs = 15e3;
|
|
5
|
+
var defaultMaxAttempts = 10;
|
|
6
|
+
var subprotocolToken = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
7
|
+
var RelayConnection = class {
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
}
|
|
11
|
+
options;
|
|
12
|
+
socketInUse;
|
|
13
|
+
timer;
|
|
14
|
+
attempt = 0;
|
|
15
|
+
everReady = false;
|
|
16
|
+
disposed = false;
|
|
17
|
+
finished = false;
|
|
18
|
+
fatalMessage;
|
|
19
|
+
start() {
|
|
20
|
+
void this.connect();
|
|
21
|
+
}
|
|
22
|
+
/** Try again now, for a link that gave up. */
|
|
23
|
+
retry() {
|
|
24
|
+
if (this.disposed || !this.finished) return;
|
|
25
|
+
clearTimeout(this.timer);
|
|
26
|
+
this.finished = false;
|
|
27
|
+
this.attempt = 0;
|
|
28
|
+
void this.connect();
|
|
29
|
+
}
|
|
30
|
+
/** Send a JSON message, if the socket is open. */
|
|
31
|
+
sendJson(value) {
|
|
32
|
+
if (this.socketInUse?.readyState === WebSocket.OPEN) {
|
|
33
|
+
this.socketInUse.send(JSON.stringify(value));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Send bytes, if the socket is open. */
|
|
37
|
+
sendBinary(data) {
|
|
38
|
+
if (this.socketInUse?.readyState === WebSocket.OPEN) this.socketInUse.send(data);
|
|
39
|
+
}
|
|
40
|
+
dispose() {
|
|
41
|
+
this.disposed = true;
|
|
42
|
+
clearTimeout(this.timer);
|
|
43
|
+
const socket = this.socketInUse;
|
|
44
|
+
this.socketInUse = void 0;
|
|
45
|
+
socket?.close(1e3);
|
|
46
|
+
}
|
|
47
|
+
async connect() {
|
|
48
|
+
if (this.disposed) return;
|
|
49
|
+
const { label } = this.options.protocol;
|
|
50
|
+
this.options.onStatus({ state: "connecting" });
|
|
51
|
+
this.fatalMessage = void 0;
|
|
52
|
+
let ticket;
|
|
53
|
+
try {
|
|
54
|
+
ticket = await this.options.getTicket?.();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (this.disposed) return;
|
|
57
|
+
const detail = error instanceof Error ? `: ${error.message}` : "";
|
|
58
|
+
this.scheduleReconnect(`Could not get a ${label} ticket${detail}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (this.disposed) return;
|
|
62
|
+
let socket;
|
|
63
|
+
try {
|
|
64
|
+
socket = new WebSocket(this.resolveUrl(), this.resolveProtocols(ticket));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
this.fail(error instanceof Error ? error.message : `Could not open the ${label}.`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (this.disposed) {
|
|
70
|
+
socket.close(1e3);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.socketInUse = socket;
|
|
74
|
+
socket.binaryType = "arraybuffer";
|
|
75
|
+
const session = {
|
|
76
|
+
ready: () => {
|
|
77
|
+
const reconnected = this.everReady;
|
|
78
|
+
this.everReady = true;
|
|
79
|
+
this.attempt = 0;
|
|
80
|
+
this.options.onStatus({ state: "open" });
|
|
81
|
+
return reconnected;
|
|
82
|
+
},
|
|
83
|
+
ended: (info) => {
|
|
84
|
+
this.finished = true;
|
|
85
|
+
const status = { state: "ended", message: info.message ?? "ended" };
|
|
86
|
+
if (info.exitCode !== void 0) status.exitCode = info.exitCode;
|
|
87
|
+
this.options.onStatus(status);
|
|
88
|
+
},
|
|
89
|
+
fatal: (message) => {
|
|
90
|
+
this.fatalMessage = message;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const { protocol } = this.options;
|
|
94
|
+
socket.addEventListener("open", () => protocol.open(socket, session));
|
|
95
|
+
socket.addEventListener("message", (event) => protocol.message(event, session));
|
|
96
|
+
socket.addEventListener("close", (event) => {
|
|
97
|
+
if (this.socketInUse === socket) this.socketInUse = void 0;
|
|
98
|
+
protocol.close?.(event, session);
|
|
99
|
+
if (this.disposed || this.finished) return;
|
|
100
|
+
if (this.fatalMessage !== void 0) this.fail(this.fatalMessage);
|
|
101
|
+
else this.scheduleReconnect();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
scheduleReconnect(reason) {
|
|
105
|
+
const { initialDelayMs, maxDelayMs, maxAttempts } = {
|
|
106
|
+
initialDelayMs: defaultInitialDelayMs,
|
|
107
|
+
maxDelayMs: defaultMaxDelayMs,
|
|
108
|
+
maxAttempts: defaultMaxAttempts,
|
|
109
|
+
...this.options.reconnect
|
|
110
|
+
};
|
|
111
|
+
if (this.attempt >= maxAttempts) {
|
|
112
|
+
this.fail(reason ?? `The connection to the ${this.options.protocol.label} was lost.`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const delay = Math.min(maxDelayMs, initialDelayMs * 2 ** this.attempt) * (0.8 + Math.random() * 0.4);
|
|
116
|
+
this.attempt += 1;
|
|
117
|
+
this.options.onStatus({
|
|
118
|
+
state: "reconnecting",
|
|
119
|
+
attempt: this.attempt,
|
|
120
|
+
retryInMs: Math.round(delay)
|
|
121
|
+
});
|
|
122
|
+
this.timer = setTimeout(() => void this.connect(), delay);
|
|
123
|
+
}
|
|
124
|
+
fail(message) {
|
|
125
|
+
this.finished = true;
|
|
126
|
+
this.options.onStatus({ state: "failed", message });
|
|
127
|
+
}
|
|
128
|
+
resolveUrl() {
|
|
129
|
+
const url = new URL(this.options.url, globalThis.location?.href);
|
|
130
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
131
|
+
else if (url.protocol === "https:") url.protocol = "wss:";
|
|
132
|
+
if (this.options.sessionName !== void 0) {
|
|
133
|
+
url.searchParams.set("session", this.options.sessionName);
|
|
134
|
+
}
|
|
135
|
+
return url.toString();
|
|
136
|
+
}
|
|
137
|
+
/** The subprotocols to offer: the relay's, plus the ticket when there is one. */
|
|
138
|
+
resolveProtocols(ticket) {
|
|
139
|
+
const { subprotocol, label } = this.options.protocol;
|
|
140
|
+
if (ticket === void 0) return [subprotocol];
|
|
141
|
+
if (!subprotocolToken.test(ticket)) {
|
|
142
|
+
throw new Error(`The ${label} ticket must use only URL-safe characters (A-Z a-z 0-9 - _ .).`);
|
|
143
|
+
}
|
|
144
|
+
return [subprotocol, `${ticketSubprotocolPrefix}${ticket}`];
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// src/relay/status.tsx
|
|
149
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
150
|
+
var statusStyle = {
|
|
151
|
+
position: "absolute",
|
|
152
|
+
top: 8,
|
|
153
|
+
right: 8,
|
|
154
|
+
padding: "2px 8px",
|
|
155
|
+
borderRadius: 4,
|
|
156
|
+
fontSize: 12,
|
|
157
|
+
background: "rgba(0, 0, 0, 0.7)",
|
|
158
|
+
color: "#fff"
|
|
159
|
+
};
|
|
160
|
+
function DefaultStatus({ status, retry }) {
|
|
161
|
+
if (status.state === "open") return null;
|
|
162
|
+
return /* @__PURE__ */ jsxs("div", { role: "status", "data-relay-status": status.state, style: statusStyle, children: [
|
|
163
|
+
describe(status),
|
|
164
|
+
status.state === "failed" ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
165
|
+
" ",
|
|
166
|
+
/* @__PURE__ */ jsx("button", { type: "button", onClick: retry, children: "Retry" })
|
|
167
|
+
] }) : null
|
|
168
|
+
] });
|
|
169
|
+
}
|
|
170
|
+
function describe(status) {
|
|
171
|
+
switch (status.state) {
|
|
172
|
+
case "loading":
|
|
173
|
+
case "connecting":
|
|
174
|
+
return "Connecting\u2026";
|
|
175
|
+
case "reconnecting":
|
|
176
|
+
return `Reconnecting in ${Math.max(1, Math.round((status.retryInMs ?? 0) / 1e3))}s\u2026`;
|
|
177
|
+
case "ended":
|
|
178
|
+
return status.exitCode === void 0 ? "Session ended" : `Session ended (exit ${status.exitCode})`;
|
|
179
|
+
case "failed":
|
|
180
|
+
return status.message ?? "Disconnected";
|
|
181
|
+
case "open":
|
|
182
|
+
return "";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/relay/ticket.ts
|
|
187
|
+
function resolveTicket(read) {
|
|
188
|
+
if (read() === void 0) return void 0;
|
|
189
|
+
return () => {
|
|
190
|
+
const ticket = read();
|
|
191
|
+
return typeof ticket === "function" ? ticket() : ticket;
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export {
|
|
196
|
+
DefaultStatus,
|
|
197
|
+
resolveTicket,
|
|
198
|
+
ticketSubprotocolPrefix,
|
|
199
|
+
RelayConnection
|
|
200
|
+
};
|
|
201
|
+
//# sourceMappingURL=chunk-5FZKTDZR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/relay/connection.ts","../src/relay/status.tsx","../src/relay/ticket.ts"],"sourcesContent":["/** The prefix a ticket travels under in `Sec-WebSocket-Protocol`. */\nexport const ticketSubprotocolPrefix = \"anvia.ticket.\";\n\nexport type RelayState =\n /** The viewer library is still loading. */\n | \"loading\"\n | \"connecting\"\n | \"open\"\n /** The link dropped and another attempt is scheduled. */\n | \"reconnecting\"\n /** The session itself ended; reconnecting would not help. */\n | \"ended\"\n /** Gave up: a fatal error, or reconnect attempts ran out. */\n | \"failed\";\n\nexport interface RelayStatus {\n state: RelayState;\n /** Reconnect attempt number while `reconnecting`. */\n attempt?: number;\n /** Delay before the next attempt while `reconnecting`. */\n retryInMs?: number;\n /** Exit code when the session ended with one. */\n exitCode?: number;\n /** Human-readable cause for `ended` and `failed`. */\n message?: string;\n}\n\nexport interface RelayReconnectOptions {\n initialDelayMs?: number;\n maxDelayMs?: number;\n /** Consecutive failed attempts before giving up. Default 10. */\n maxAttempts?: number;\n}\n\n/** What a relay protocol can tell the connection about the socket it is driving. */\nexport interface RelaySession {\n /**\n * The relay accepted this attachment. Returns whether an earlier attachment had also been\n * accepted, so a viewer knows to start clean after a reconnect.\n */\n ready(): boolean;\n /** The session itself is over, so the socket closing is not a reason to reconnect. */\n ended(info: { message?: string | undefined; exitCode?: number | undefined }): void;\n /** Something retrying cannot fix. It takes effect when the socket closes. */\n fatal(message: string): void;\n}\n\n/** The part of a relay connection that differs between a terminal and a desktop. */\nexport interface RelayProtocol {\n /** The subprotocol the hub's upgrade handler selects. */\n subprotocol: string;\n /** Names the thing in error messages: \"terminal\", \"desktop\". */\n label: string;\n /** The socket opened. */\n open(socket: WebSocket, session: RelaySession): void;\n message(event: MessageEvent, session: RelaySession): void;\n /** The socket closed. A protocol that learns why from the close frame can call `fatal` here. */\n close?(event: CloseEvent, session: RelaySession): void;\n}\n\nexport interface RelayConnectionOptions {\n /** `ws:`, `wss:`, `http:`, `https:`, or a path relative to the page. */\n url: string;\n /** Appended as `?session=` so the server can pick the session. The server stays in charge. */\n sessionName?: string | undefined;\n /** Called for every attempt, because tickets are single use. */\n getTicket?: (() => string | Promise<string>) | undefined;\n onStatus: (status: RelayStatus) => void;\n reconnect?: RelayReconnectOptions | undefined;\n protocol: RelayProtocol;\n}\n\nconst defaultInitialDelayMs = 500;\nconst defaultMaxDelayMs = 15_000;\nconst defaultMaxAttempts = 10;\n/** Characters a ticket may use, since it travels as a WebSocket subprotocol (an HTTP token). */\nconst subprotocolToken = /^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/;\n\n/**\n * One browser link to a hub relay: opens the socket with a fresh ticket, reconnects with backoff,\n * and reports its state. What the socket carries is the protocol's business, so this knows nothing\n * about terminals or desktops and is shared by both.\n */\nexport class RelayConnection {\n private socketInUse: WebSocket | undefined;\n private timer: ReturnType<typeof setTimeout> | undefined;\n private attempt = 0;\n private everReady = false;\n private disposed = false;\n private finished = false;\n private fatalMessage: string | undefined;\n\n constructor(private readonly options: RelayConnectionOptions) {}\n\n start(): void {\n void this.connect();\n }\n\n /** Try again now, for a link that gave up. */\n retry(): void {\n // Only a link that gave up needs this; otherwise an attempt is already under way.\n if (this.disposed || !this.finished) return;\n clearTimeout(this.timer);\n this.finished = false;\n this.attempt = 0;\n void this.connect();\n }\n\n /** Send a JSON message, if the socket is open. */\n sendJson(value: Record<string, unknown>): void {\n if (this.socketInUse?.readyState === WebSocket.OPEN) {\n this.socketInUse.send(JSON.stringify(value));\n }\n }\n\n /** Send bytes, if the socket is open. */\n sendBinary(data: ArrayBufferLike | ArrayBufferView): void {\n if (this.socketInUse?.readyState === WebSocket.OPEN) this.socketInUse.send(data);\n }\n\n dispose(): void {\n this.disposed = true;\n clearTimeout(this.timer);\n const socket = this.socketInUse;\n this.socketInUse = undefined;\n socket?.close(1000);\n }\n\n private async connect(): Promise<void> {\n if (this.disposed) return;\n const { label } = this.options.protocol;\n this.options.onStatus({ state: \"connecting\" });\n // A fatal error belongs to the attempt that hit it. Without this, one that was retried away\n // would fail every later drop.\n this.fatalMessage = undefined;\n\n let ticket: string | undefined;\n try {\n ticket = await this.options.getTicket?.();\n } catch (error) {\n // Fetching a ticket usually fails because the control plane is restarting, which is exactly\n // when reconnecting is wanted, so it backs off like a dropped socket.\n if (this.disposed) return;\n const detail = error instanceof Error ? `: ${error.message}` : \"\";\n this.scheduleReconnect(`Could not get a ${label} ticket${detail}`);\n return;\n }\n if (this.disposed) return;\n\n let socket: WebSocket;\n try {\n socket = new WebSocket(this.resolveUrl(), this.resolveProtocols(ticket));\n } catch (error) {\n // A bad URL or a malformed ticket: trying again cannot fix either.\n this.fail(error instanceof Error ? error.message : `Could not open the ${label}.`);\n return;\n }\n if (this.disposed) {\n socket.close(1000);\n return;\n }\n this.socketInUse = socket;\n socket.binaryType = \"arraybuffer\";\n\n const session: RelaySession = {\n ready: () => {\n const reconnected = this.everReady;\n this.everReady = true;\n this.attempt = 0;\n this.options.onStatus({ state: \"open\" });\n return reconnected;\n },\n ended: (info) => {\n this.finished = true;\n const status: RelayStatus = { state: \"ended\", message: info.message ?? \"ended\" };\n if (info.exitCode !== undefined) status.exitCode = info.exitCode;\n this.options.onStatus(status);\n },\n fatal: (message) => {\n this.fatalMessage = message;\n },\n };\n const { protocol } = this.options;\n socket.addEventListener(\"open\", () => protocol.open(socket, session));\n socket.addEventListener(\"message\", (event: MessageEvent) => protocol.message(event, session));\n socket.addEventListener(\"close\", (event: CloseEvent) => {\n if (this.socketInUse === socket) this.socketInUse = undefined;\n protocol.close?.(event, session);\n if (this.disposed || this.finished) return;\n if (this.fatalMessage !== undefined) this.fail(this.fatalMessage);\n else this.scheduleReconnect();\n });\n }\n\n private scheduleReconnect(reason?: string): void {\n const { initialDelayMs, maxDelayMs, maxAttempts } = {\n initialDelayMs: defaultInitialDelayMs,\n maxDelayMs: defaultMaxDelayMs,\n maxAttempts: defaultMaxAttempts,\n ...this.options.reconnect,\n };\n if (this.attempt >= maxAttempts) {\n this.fail(reason ?? `The connection to the ${this.options.protocol.label} was lost.`);\n return;\n }\n const delay =\n Math.min(maxDelayMs, initialDelayMs * 2 ** this.attempt) * (0.8 + Math.random() * 0.4);\n this.attempt += 1;\n this.options.onStatus({\n state: \"reconnecting\",\n attempt: this.attempt,\n retryInMs: Math.round(delay),\n });\n this.timer = setTimeout(() => void this.connect(), delay);\n }\n\n private fail(message: string): void {\n this.finished = true;\n this.options.onStatus({ state: \"failed\", message });\n }\n\n private resolveUrl(): string {\n const url = new URL(this.options.url, globalThis.location?.href);\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n else if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (this.options.sessionName !== undefined) {\n url.searchParams.set(\"session\", this.options.sessionName);\n }\n return url.toString();\n }\n\n /** The subprotocols to offer: the relay's, plus the ticket when there is one. */\n private resolveProtocols(ticket: string | undefined): string[] {\n const { subprotocol, label } = this.options.protocol;\n if (ticket === undefined) return [subprotocol];\n if (!subprotocolToken.test(ticket)) {\n throw new Error(`The ${label} ticket must use only URL-safe characters (A-Z a-z 0-9 - _ .).`);\n }\n return [subprotocol, `${ticketSubprotocolPrefix}${ticket}`];\n }\n}\n","import type { CSSProperties } from \"react\";\n\nimport type { RelayStatus } from \"./connection\";\n\nconst statusStyle: CSSProperties = {\n position: \"absolute\",\n top: 8,\n right: 8,\n padding: \"2px 8px\",\n borderRadius: 4,\n fontSize: 12,\n background: \"rgba(0, 0, 0, 0.7)\",\n color: \"#fff\",\n};\n\nexport function DefaultStatus({ status, retry }: { status: RelayStatus; retry: () => void }) {\n if (status.state === \"open\") return null;\n return (\n <div role=\"status\" data-relay-status={status.state} style={statusStyle}>\n {describe(status)}\n {status.state === \"failed\" ? (\n <>\n {\" \"}\n <button type=\"button\" onClick={retry}>\n Retry\n </button>\n </>\n ) : null}\n </div>\n );\n}\n\nfunction describe(status: RelayStatus): string {\n switch (status.state) {\n case \"loading\":\n case \"connecting\":\n return \"Connecting…\";\n case \"reconnecting\":\n return `Reconnecting in ${Math.max(1, Math.round((status.retryInMs ?? 0) / 1_000))}s…`;\n case \"ended\":\n return status.exitCode === undefined\n ? \"Session ended\"\n : `Session ended (exit ${status.exitCode})`;\n case \"failed\":\n return status.message ?? \"Disconnected\";\n case \"open\":\n return \"\";\n }\n}\n","/** What a viewer's `ticket` prop can be: a ticket, or a function returning one per connection. */\nexport type TicketSource = string | (() => string | Promise<string>);\n\n/**\n * Turn the latest `ticket` prop into the per-attempt function a connection wants. The prop is read\n * on every attempt, so a function is called afresh each time: tickets are single use.\n */\nexport function resolveTicket(\n read: () => TicketSource | undefined,\n): (() => string | Promise<string>) | undefined {\n if (read() === undefined) return undefined;\n return () => {\n const ticket = read();\n return typeof ticket === \"function\" ? ticket() : (ticket as string);\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { P as PrimitiveProps } from '../render-CRKWam2v.js';
|
|
4
|
+
import { T as TicketSource, R as RelayReconnectOptions, a as RelayStatus } from '../ticket-pexI1zIa.js';
|
|
5
|
+
export { b as SandboxDesktopState, t as ticketSubprotocolPrefix } from '../ticket-pexI1zIa.js';
|
|
6
|
+
|
|
7
|
+
/** Subprotocol the hub's `handleDesktopUpgrade` selects. */
|
|
8
|
+
declare const desktopSubprotocol = "anvia.desktop.v1";
|
|
9
|
+
/** Who is driving the desktop, as the relay reports it to this viewer. */
|
|
10
|
+
interface SandboxDesktopControl {
|
|
11
|
+
/** Someone has taken control, which pauses the agent. */
|
|
12
|
+
held: boolean;
|
|
13
|
+
/** Who. Present when `held`. */
|
|
14
|
+
holder?: string;
|
|
15
|
+
/** When the hold lapses unless renewed. */
|
|
16
|
+
expiresAt?: string;
|
|
17
|
+
/** This viewer is the holder. */
|
|
18
|
+
you: boolean;
|
|
19
|
+
/** This viewer may only watch. */
|
|
20
|
+
readOnly: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SandboxDesktopHandle {
|
|
24
|
+
focus(): void;
|
|
25
|
+
/** Reconnect now, after the viewer gave up. */
|
|
26
|
+
reconnect(): void;
|
|
27
|
+
/** Take control, which pauses the agent. */
|
|
28
|
+
takeOver(): void;
|
|
29
|
+
/** Hand control back. */
|
|
30
|
+
release(): void;
|
|
31
|
+
}
|
|
32
|
+
interface SandboxDesktopNotice {
|
|
33
|
+
code: string;
|
|
34
|
+
message: string;
|
|
35
|
+
}
|
|
36
|
+
/** What `renderControls` is given to draw its own take-over bar. */
|
|
37
|
+
interface SandboxDesktopControlsState {
|
|
38
|
+
status: RelayStatus;
|
|
39
|
+
/** Undefined until the relay has said who has control. */
|
|
40
|
+
control: SandboxDesktopControl | undefined;
|
|
41
|
+
/** This viewer cannot send input, because of the prop or because the ticket says so. */
|
|
42
|
+
readOnly: boolean;
|
|
43
|
+
/** The latest failed request, such as taking over while someone else holds control. */
|
|
44
|
+
notice: SandboxDesktopNotice | undefined;
|
|
45
|
+
takeOver(): void;
|
|
46
|
+
release(): void;
|
|
47
|
+
}
|
|
48
|
+
type SandboxDesktopProps = Omit<PrimitiveProps<"div">, "children" | "onError"> & {
|
|
49
|
+
/**
|
|
50
|
+
* The hub's desktop endpoint (`hub.handleDesktopUpgrade`): `ws:`/`wss:`, `http:`/`https:`, or a
|
|
51
|
+
* path relative to this page.
|
|
52
|
+
*/
|
|
53
|
+
url: string;
|
|
54
|
+
/**
|
|
55
|
+
* A one-time ticket, or a function returning one. Pass a function: tickets are single use, so
|
|
56
|
+
* each reconnect needs a fresh one. It travels as a WebSocket subprotocol, not in the URL.
|
|
57
|
+
*/
|
|
58
|
+
ticket?: TicketSource;
|
|
59
|
+
/** Watch only. The server enforces it too; this also stops the viewer sending input. */
|
|
60
|
+
readOnly?: boolean;
|
|
61
|
+
/** Scale the desktop to fit the element. Default true. */
|
|
62
|
+
scaleToFit?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* JPEG quality asked of the server, 0 (smallest, blockiest) to 9 (best, most data). Default 6.
|
|
65
|
+
* Lower it for a slow link; it applies to the live connection.
|
|
66
|
+
*/
|
|
67
|
+
qualityLevel?: number;
|
|
68
|
+
/**
|
|
69
|
+
* How hard the server compresses, 0 (fastest, most data) to 9 (slowest, least data). Default 2,
|
|
70
|
+
* which keeps the server's CPU low. Raise it for a slow link; it applies to the live connection.
|
|
71
|
+
*/
|
|
72
|
+
compressionLevel?: number;
|
|
73
|
+
reconnect?: RelayReconnectOptions;
|
|
74
|
+
onStatusChange?: (status: RelayStatus) => void;
|
|
75
|
+
/** Called whenever the relay reports who has control. */
|
|
76
|
+
onControlChange?: (control: SandboxDesktopControl) => void;
|
|
77
|
+
/** Replace the default status line. Return null to show nothing. */
|
|
78
|
+
renderStatus?: (status: RelayStatus, retry: () => void) => ReactNode;
|
|
79
|
+
/** Replace the default take-over bar. Return null to show nothing. */
|
|
80
|
+
renderControls?: (state: SandboxDesktopControlsState) => ReactNode;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* A viewer for a remote sandbox's desktop, drawn with noVNC. noVNC is loaded on demand, so it costs
|
|
84
|
+
* nothing until a desktop mounts. It connects to the hub's desktop relay, reconnects with a fresh
|
|
85
|
+
* ticket when the link drops, and lets a person take over control from the agent.
|
|
86
|
+
*/
|
|
87
|
+
declare const SandboxDesktop: react.ForwardRefExoticComponent<Omit<PrimitiveProps<"div">, "children" | "onError"> & {
|
|
88
|
+
/**
|
|
89
|
+
* The hub's desktop endpoint (`hub.handleDesktopUpgrade`): `ws:`/`wss:`, `http:`/`https:`, or a
|
|
90
|
+
* path relative to this page.
|
|
91
|
+
*/
|
|
92
|
+
url: string;
|
|
93
|
+
/**
|
|
94
|
+
* A one-time ticket, or a function returning one. Pass a function: tickets are single use, so
|
|
95
|
+
* each reconnect needs a fresh one. It travels as a WebSocket subprotocol, not in the URL.
|
|
96
|
+
*/
|
|
97
|
+
ticket?: TicketSource;
|
|
98
|
+
/** Watch only. The server enforces it too; this also stops the viewer sending input. */
|
|
99
|
+
readOnly?: boolean;
|
|
100
|
+
/** Scale the desktop to fit the element. Default true. */
|
|
101
|
+
scaleToFit?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* JPEG quality asked of the server, 0 (smallest, blockiest) to 9 (best, most data). Default 6.
|
|
104
|
+
* Lower it for a slow link; it applies to the live connection.
|
|
105
|
+
*/
|
|
106
|
+
qualityLevel?: number;
|
|
107
|
+
/**
|
|
108
|
+
* How hard the server compresses, 0 (fastest, most data) to 9 (slowest, least data). Default 2,
|
|
109
|
+
* which keeps the server's CPU low. Raise it for a slow link; it applies to the live connection.
|
|
110
|
+
*/
|
|
111
|
+
compressionLevel?: number;
|
|
112
|
+
reconnect?: RelayReconnectOptions;
|
|
113
|
+
onStatusChange?: (status: RelayStatus) => void;
|
|
114
|
+
/** Called whenever the relay reports who has control. */
|
|
115
|
+
onControlChange?: (control: SandboxDesktopControl) => void;
|
|
116
|
+
/** Replace the default status line. Return null to show nothing. */
|
|
117
|
+
renderStatus?: (status: RelayStatus, retry: () => void) => ReactNode;
|
|
118
|
+
/** Replace the default take-over bar. Return null to show nothing. */
|
|
119
|
+
renderControls?: (state: SandboxDesktopControlsState) => ReactNode;
|
|
120
|
+
} & react.RefAttributes<SandboxDesktopHandle>>;
|
|
121
|
+
|
|
122
|
+
export { SandboxDesktop, type SandboxDesktopControl, type SandboxDesktopControlsState, type SandboxDesktopHandle, type SandboxDesktopNotice, type SandboxDesktopProps, RelayReconnectOptions as SandboxDesktopReconnectOptions, RelayStatus as SandboxDesktopStatus, desktopSubprotocol };
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DefaultStatus,
|
|
3
|
+
RelayConnection,
|
|
4
|
+
resolveTicket,
|
|
5
|
+
ticketSubprotocolPrefix
|
|
6
|
+
} from "../chunk-5FZKTDZR.js";
|
|
7
|
+
|
|
8
|
+
// src/sandbox-desktop/desktop.tsx
|
|
9
|
+
import {
|
|
10
|
+
forwardRef,
|
|
11
|
+
useEffect,
|
|
12
|
+
useImperativeHandle,
|
|
13
|
+
useRef,
|
|
14
|
+
useState
|
|
15
|
+
} from "react";
|
|
16
|
+
|
|
17
|
+
// src/sandbox-desktop/vnc-channel.ts
|
|
18
|
+
var OPEN = 1;
|
|
19
|
+
var CLOSED = 3;
|
|
20
|
+
var VncChannel = class {
|
|
21
|
+
constructor(link, protocol = "") {
|
|
22
|
+
this.link = link;
|
|
23
|
+
this.protocol = protocol;
|
|
24
|
+
}
|
|
25
|
+
link;
|
|
26
|
+
binaryType = "arraybuffer";
|
|
27
|
+
protocol;
|
|
28
|
+
onerror = null;
|
|
29
|
+
onopen = null;
|
|
30
|
+
onclose = null;
|
|
31
|
+
handler = null;
|
|
32
|
+
backlog = [];
|
|
33
|
+
state = OPEN;
|
|
34
|
+
get readyState() {
|
|
35
|
+
return this.state;
|
|
36
|
+
}
|
|
37
|
+
get onmessage() {
|
|
38
|
+
return this.handler;
|
|
39
|
+
}
|
|
40
|
+
set onmessage(handler) {
|
|
41
|
+
this.handler = handler;
|
|
42
|
+
if (handler === null) return;
|
|
43
|
+
for (const data of this.backlog.splice(0)) handler({ data });
|
|
44
|
+
}
|
|
45
|
+
send(data) {
|
|
46
|
+
if (this.state === OPEN) this.link.send(data);
|
|
47
|
+
}
|
|
48
|
+
/** noVNC is done with the connection. */
|
|
49
|
+
close() {
|
|
50
|
+
if (this.state !== OPEN) return;
|
|
51
|
+
this.state = CLOSED;
|
|
52
|
+
this.link.close();
|
|
53
|
+
}
|
|
54
|
+
/** A VNC frame arrived from the relay. */
|
|
55
|
+
receive(data) {
|
|
56
|
+
if (this.handler === null) this.backlog.push(data);
|
|
57
|
+
else this.handler({ data });
|
|
58
|
+
}
|
|
59
|
+
/** The relay closed the socket. */
|
|
60
|
+
closed(event) {
|
|
61
|
+
if (this.state === CLOSED) return;
|
|
62
|
+
this.state = CLOSED;
|
|
63
|
+
this.backlog = [];
|
|
64
|
+
this.onclose?.(event);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// src/sandbox-desktop/connection.ts
|
|
69
|
+
var desktopSubprotocol = "anvia.desktop.v1";
|
|
70
|
+
var retryableCodes = /* @__PURE__ */ new Set(["sandbox_unavailable", "timeout"]);
|
|
71
|
+
var closeReason = /^([a-z_]+): (.*)$/s;
|
|
72
|
+
var policyViolation = 1008;
|
|
73
|
+
var DesktopConnection = class {
|
|
74
|
+
relay;
|
|
75
|
+
constructor(options) {
|
|
76
|
+
let channel;
|
|
77
|
+
let accepted = false;
|
|
78
|
+
const protocol = {
|
|
79
|
+
subprotocol: desktopSubprotocol,
|
|
80
|
+
label: "desktop",
|
|
81
|
+
open: (socket) => {
|
|
82
|
+
accepted = false;
|
|
83
|
+
channel = new VncChannel(
|
|
84
|
+
{ send: (data) => socket.send(data), close: () => socket.close(1e3) },
|
|
85
|
+
socket.protocol
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
message: (event, session) => {
|
|
89
|
+
if (!accepted && channel !== void 0) {
|
|
90
|
+
accepted = true;
|
|
91
|
+
options.onChannel(channel, session.ready());
|
|
92
|
+
}
|
|
93
|
+
if (typeof event.data !== "string") {
|
|
94
|
+
channel?.receive(event.data);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const message = parseText(event.data);
|
|
98
|
+
if (message?.type === "control") {
|
|
99
|
+
const { type: _type, ...control } = message;
|
|
100
|
+
options.onControl(control);
|
|
101
|
+
} else if (message?.type === "error") {
|
|
102
|
+
options.onNotice?.({
|
|
103
|
+
code: message.code ?? "protocol",
|
|
104
|
+
message: message.message ?? "The request failed."
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
close: (event, session) => {
|
|
109
|
+
channel?.closed(event);
|
|
110
|
+
const match = closeReason.exec(event.reason);
|
|
111
|
+
if (match !== null && !retryableCodes.has(match[1] ?? "")) {
|
|
112
|
+
session.fatal(match[2] ?? "The desktop could not be opened.");
|
|
113
|
+
} else if (event.code === policyViolation) {
|
|
114
|
+
session.fatal(event.reason || "The desktop relay refused what the viewer sent.");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
this.relay = new RelayConnection({
|
|
119
|
+
url: options.url,
|
|
120
|
+
getTicket: options.getTicket,
|
|
121
|
+
onStatus: options.onStatus,
|
|
122
|
+
reconnect: options.reconnect,
|
|
123
|
+
protocol
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
start() {
|
|
127
|
+
this.relay.start();
|
|
128
|
+
}
|
|
129
|
+
retry() {
|
|
130
|
+
this.relay.retry();
|
|
131
|
+
}
|
|
132
|
+
/** Ask for control, which pauses the agent's inputs. The answer arrives as a `control` update. */
|
|
133
|
+
takeOver() {
|
|
134
|
+
this.relay.sendJson({ type: "takeover" });
|
|
135
|
+
}
|
|
136
|
+
release() {
|
|
137
|
+
this.relay.sendJson({ type: "release" });
|
|
138
|
+
}
|
|
139
|
+
dispose() {
|
|
140
|
+
this.relay.dispose();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
function parseText(text) {
|
|
144
|
+
try {
|
|
145
|
+
const value = JSON.parse(text);
|
|
146
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
147
|
+
const type = value.type;
|
|
148
|
+
return type === "control" || type === "error" ? value : void 0;
|
|
149
|
+
} catch {
|
|
150
|
+
return void 0;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/sandbox-desktop/desktop.tsx
|
|
155
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
156
|
+
var defaultQualityLevel = 6;
|
|
157
|
+
var defaultCompressionLevel = 2;
|
|
158
|
+
function level(value, fallback) {
|
|
159
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
160
|
+
return Math.min(9, Math.max(0, Math.round(value)));
|
|
161
|
+
}
|
|
162
|
+
var rootStyle = { position: "relative", width: "100%", height: "100%" };
|
|
163
|
+
var screenStyle = { width: "100%", height: "100%" };
|
|
164
|
+
var SandboxDesktop = forwardRef(
|
|
165
|
+
function SandboxDesktop2({
|
|
166
|
+
url,
|
|
167
|
+
ticket,
|
|
168
|
+
readOnly = false,
|
|
169
|
+
scaleToFit = true,
|
|
170
|
+
qualityLevel,
|
|
171
|
+
compressionLevel,
|
|
172
|
+
reconnect,
|
|
173
|
+
onStatusChange,
|
|
174
|
+
onControlChange,
|
|
175
|
+
renderStatus,
|
|
176
|
+
renderControls,
|
|
177
|
+
style,
|
|
178
|
+
...rest
|
|
179
|
+
}, ref) {
|
|
180
|
+
const screen = useRef(null);
|
|
181
|
+
const connection = useRef(void 0);
|
|
182
|
+
const viewer = useRef(void 0);
|
|
183
|
+
const [status, setStatus] = useState({ state: "loading" });
|
|
184
|
+
const [control, setControl] = useState(void 0);
|
|
185
|
+
const [notice, setNotice] = useState(void 0);
|
|
186
|
+
const viewOnly = readOnly || control?.readOnly === true || control?.held === true && !control.you;
|
|
187
|
+
const latest = useRef({
|
|
188
|
+
ticket,
|
|
189
|
+
reconnect,
|
|
190
|
+
onStatusChange,
|
|
191
|
+
onControlChange,
|
|
192
|
+
scaleToFit,
|
|
193
|
+
viewOnly,
|
|
194
|
+
quality: level(qualityLevel, defaultQualityLevel),
|
|
195
|
+
compression: level(compressionLevel, defaultCompressionLevel)
|
|
196
|
+
});
|
|
197
|
+
latest.current = {
|
|
198
|
+
ticket,
|
|
199
|
+
reconnect,
|
|
200
|
+
onStatusChange,
|
|
201
|
+
onControlChange,
|
|
202
|
+
scaleToFit,
|
|
203
|
+
viewOnly,
|
|
204
|
+
quality: level(qualityLevel, defaultQualityLevel),
|
|
205
|
+
compression: level(compressionLevel, defaultCompressionLevel)
|
|
206
|
+
};
|
|
207
|
+
useImperativeHandle(
|
|
208
|
+
ref,
|
|
209
|
+
() => ({
|
|
210
|
+
focus: () => viewer.current?.focus(),
|
|
211
|
+
reconnect: () => connection.current?.retry(),
|
|
212
|
+
takeOver: () => connection.current?.takeOver(),
|
|
213
|
+
release: () => connection.current?.release()
|
|
214
|
+
}),
|
|
215
|
+
[]
|
|
216
|
+
);
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (viewer.current !== void 0) viewer.current.viewOnly = viewOnly;
|
|
219
|
+
}, [viewOnly]);
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (viewer.current !== void 0) viewer.current.scaleViewport = scaleToFit;
|
|
222
|
+
}, [scaleToFit]);
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (viewer.current !== void 0) viewer.current.qualityLevel = latest.current.quality;
|
|
225
|
+
}, [qualityLevel]);
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (viewer.current !== void 0) {
|
|
228
|
+
viewer.current.compressionLevel = latest.current.compression;
|
|
229
|
+
}
|
|
230
|
+
}, [compressionLevel]);
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
const container = screen.current;
|
|
233
|
+
if (container === null) return void 0;
|
|
234
|
+
let disposed = false;
|
|
235
|
+
let teardown;
|
|
236
|
+
const publish = (next) => {
|
|
237
|
+
if (disposed) return;
|
|
238
|
+
setStatus(next);
|
|
239
|
+
if (next.state !== "open") setControl(void 0);
|
|
240
|
+
latest.current.onStatusChange?.(next);
|
|
241
|
+
};
|
|
242
|
+
publish({ state: "loading" });
|
|
243
|
+
void (async () => {
|
|
244
|
+
const { default: Rfb } = await import("@novnc/novnc");
|
|
245
|
+
if (disposed) return;
|
|
246
|
+
const link = new DesktopConnection({
|
|
247
|
+
url,
|
|
248
|
+
getTicket: resolveTicket(() => latest.current.ticket),
|
|
249
|
+
reconnect: latest.current.reconnect,
|
|
250
|
+
onStatus: publish,
|
|
251
|
+
onChannel: (channel) => {
|
|
252
|
+
viewer.current?.disconnect();
|
|
253
|
+
const rfb = new Rfb(container, channel);
|
|
254
|
+
rfb.scaleViewport = latest.current.scaleToFit;
|
|
255
|
+
rfb.clipViewport = false;
|
|
256
|
+
rfb.resizeSession = false;
|
|
257
|
+
rfb.viewOnly = latest.current.viewOnly;
|
|
258
|
+
rfb.qualityLevel = latest.current.quality;
|
|
259
|
+
rfb.compressionLevel = latest.current.compression;
|
|
260
|
+
viewer.current = rfb;
|
|
261
|
+
},
|
|
262
|
+
onControl: (next) => {
|
|
263
|
+
if (disposed) return;
|
|
264
|
+
setControl(next);
|
|
265
|
+
setNotice(void 0);
|
|
266
|
+
latest.current.onControlChange?.(next);
|
|
267
|
+
},
|
|
268
|
+
onNotice: (next) => {
|
|
269
|
+
if (!disposed) setNotice(next);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
connection.current = link;
|
|
273
|
+
link.start();
|
|
274
|
+
teardown = () => {
|
|
275
|
+
link.dispose();
|
|
276
|
+
viewer.current?.disconnect();
|
|
277
|
+
viewer.current = void 0;
|
|
278
|
+
connection.current = void 0;
|
|
279
|
+
};
|
|
280
|
+
})().catch((error) => {
|
|
281
|
+
publish({
|
|
282
|
+
state: "failed",
|
|
283
|
+
message: error instanceof Error ? error.message : "Could not load the desktop viewer."
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
return () => {
|
|
287
|
+
disposed = true;
|
|
288
|
+
teardown?.();
|
|
289
|
+
};
|
|
290
|
+
}, [url]);
|
|
291
|
+
const retry = () => connection.current?.retry();
|
|
292
|
+
const takeOver = () => connection.current?.takeOver();
|
|
293
|
+
const release = () => connection.current?.release();
|
|
294
|
+
const statusNode = renderStatus === void 0 ? /* @__PURE__ */ jsx(DefaultStatus, { status, retry }) : renderStatus(status, retry);
|
|
295
|
+
const controlsState = {
|
|
296
|
+
status,
|
|
297
|
+
control,
|
|
298
|
+
readOnly: readOnly || control?.readOnly === true,
|
|
299
|
+
notice,
|
|
300
|
+
takeOver,
|
|
301
|
+
release
|
|
302
|
+
};
|
|
303
|
+
const controlsNode = renderControls === void 0 ? /* @__PURE__ */ jsx(DefaultControls, { ...controlsState }) : renderControls(controlsState);
|
|
304
|
+
return /* @__PURE__ */ jsxs(
|
|
305
|
+
"div",
|
|
306
|
+
{
|
|
307
|
+
...rest,
|
|
308
|
+
"data-state": status.state,
|
|
309
|
+
"data-readonly": viewOnly ? "" : void 0,
|
|
310
|
+
"data-control": control?.you === true ? "you" : control?.held === true ? "other" : "none",
|
|
311
|
+
style: { ...rootStyle, ...style },
|
|
312
|
+
children: [
|
|
313
|
+
/* @__PURE__ */ jsx("div", { ref: screen, style: screenStyle }),
|
|
314
|
+
controlsNode,
|
|
315
|
+
statusNode
|
|
316
|
+
]
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
var controlsStyle = {
|
|
322
|
+
position: "absolute",
|
|
323
|
+
top: 8,
|
|
324
|
+
left: 8,
|
|
325
|
+
zIndex: 1,
|
|
326
|
+
display: "flex",
|
|
327
|
+
gap: 8,
|
|
328
|
+
alignItems: "center",
|
|
329
|
+
padding: "2px 8px",
|
|
330
|
+
borderRadius: 4,
|
|
331
|
+
fontSize: 12,
|
|
332
|
+
background: "rgba(0, 0, 0, 0.7)",
|
|
333
|
+
color: "#fff"
|
|
334
|
+
};
|
|
335
|
+
function DefaultControls({
|
|
336
|
+
status,
|
|
337
|
+
control,
|
|
338
|
+
readOnly,
|
|
339
|
+
notice,
|
|
340
|
+
takeOver,
|
|
341
|
+
release
|
|
342
|
+
}) {
|
|
343
|
+
if (status.state !== "open" || control === void 0) return null;
|
|
344
|
+
const who = control.you ? "You have control" : control.held ? `${control.holder ?? "Someone"} has control` : readOnly ? "View only" : "The agent has control";
|
|
345
|
+
return /* @__PURE__ */ jsxs("div", { "data-sandbox-desktop-controls": "", style: controlsStyle, children: [
|
|
346
|
+
/* @__PURE__ */ jsx("span", { children: who }),
|
|
347
|
+
control.you ? /* @__PURE__ */ jsx("button", { type: "button", onClick: release, children: "Release" }) : !readOnly && !control.held ? /* @__PURE__ */ jsx("button", { type: "button", onClick: takeOver, children: "Take over" }) : null,
|
|
348
|
+
notice === void 0 ? null : /* @__PURE__ */ jsx("span", { role: "alert", children: notice.message })
|
|
349
|
+
] });
|
|
350
|
+
}
|
|
351
|
+
export {
|
|
352
|
+
SandboxDesktop,
|
|
353
|
+
desktopSubprotocol,
|
|
354
|
+
ticketSubprotocolPrefix
|
|
355
|
+
};
|
|
356
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/sandbox-desktop/desktop.tsx","../../src/sandbox-desktop/vnc-channel.ts","../../src/sandbox-desktop/connection.ts"],"sourcesContent":["import type RFB from \"@novnc/novnc\";\nimport {\n forwardRef,\n useEffect,\n useImperativeHandle,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\n\nimport { type PrimitiveProps } from \"../primitives\";\nimport type { RelayReconnectOptions, RelayStatus } from \"../relay/connection\";\nimport { DefaultStatus } from \"../relay/status\";\nimport { type TicketSource, resolveTicket } from \"../relay/ticket\";\nimport { DesktopConnection, type SandboxDesktopControl } from \"./connection\";\n\nexport interface SandboxDesktopHandle {\n focus(): void;\n /** Reconnect now, after the viewer gave up. */\n reconnect(): void;\n /** Take control, which pauses the agent. */\n takeOver(): void;\n /** Hand control back. */\n release(): void;\n}\n\nexport interface SandboxDesktopNotice {\n code: string;\n message: string;\n}\n\n/** What `renderControls` is given to draw its own take-over bar. */\nexport interface SandboxDesktopControlsState {\n status: RelayStatus;\n /** Undefined until the relay has said who has control. */\n control: SandboxDesktopControl | undefined;\n /** This viewer cannot send input, because of the prop or because the ticket says so. */\n readOnly: boolean;\n /** The latest failed request, such as taking over while someone else holds control. */\n notice: SandboxDesktopNotice | undefined;\n takeOver(): void;\n release(): void;\n}\n\nexport type SandboxDesktopProps = Omit<PrimitiveProps<\"div\">, \"children\" | \"onError\"> & {\n /**\n * The hub's desktop endpoint (`hub.handleDesktopUpgrade`): `ws:`/`wss:`, `http:`/`https:`, or a\n * path relative to this page.\n */\n url: string;\n /**\n * A one-time ticket, or a function returning one. Pass a function: tickets are single use, so\n * each reconnect needs a fresh one. It travels as a WebSocket subprotocol, not in the URL.\n */\n ticket?: TicketSource;\n /** Watch only. The server enforces it too; this also stops the viewer sending input. */\n readOnly?: boolean;\n /** Scale the desktop to fit the element. Default true. */\n scaleToFit?: boolean;\n /**\n * JPEG quality asked of the server, 0 (smallest, blockiest) to 9 (best, most data). Default 6.\n * Lower it for a slow link; it applies to the live connection.\n */\n qualityLevel?: number;\n /**\n * How hard the server compresses, 0 (fastest, most data) to 9 (slowest, least data). Default 2,\n * which keeps the server's CPU low. Raise it for a slow link; it applies to the live connection.\n */\n compressionLevel?: number;\n reconnect?: RelayReconnectOptions;\n onStatusChange?: (status: RelayStatus) => void;\n /** Called whenever the relay reports who has control. */\n onControlChange?: (control: SandboxDesktopControl) => void;\n /** Replace the default status line. Return null to show nothing. */\n renderStatus?: (status: RelayStatus, retry: () => void) => ReactNode;\n /** Replace the default take-over bar. Return null to show nothing. */\n renderControls?: (state: SandboxDesktopControlsState) => ReactNode;\n};\n\nconst defaultQualityLevel = 6;\nconst defaultCompressionLevel = 2;\n\n/** A level noVNC accepts: a whole number from 0 to 9. Anything else falls back to the default. */\nfunction level(value: number | undefined, fallback: number): number {\n if (value === undefined || !Number.isFinite(value)) return fallback;\n return Math.min(9, Math.max(0, Math.round(value)));\n}\n\nconst rootStyle: CSSProperties = { position: \"relative\", width: \"100%\", height: \"100%\" };\nconst screenStyle: CSSProperties = { width: \"100%\", height: \"100%\" };\n\n/**\n * A viewer for a remote sandbox's desktop, drawn with noVNC. noVNC is loaded on demand, so it costs\n * nothing until a desktop mounts. It connects to the hub's desktop relay, reconnects with a fresh\n * ticket when the link drops, and lets a person take over control from the agent.\n */\nexport const SandboxDesktop = forwardRef<SandboxDesktopHandle, SandboxDesktopProps>(\n function SandboxDesktop(\n {\n url,\n ticket,\n readOnly = false,\n scaleToFit = true,\n qualityLevel,\n compressionLevel,\n reconnect,\n onStatusChange,\n onControlChange,\n renderStatus,\n renderControls,\n style,\n ...rest\n },\n ref,\n ) {\n const screen = useRef<HTMLDivElement>(null);\n const connection = useRef<DesktopConnection | undefined>(undefined);\n const viewer = useRef<RFB | undefined>(undefined);\n const [status, setStatus] = useState<RelayStatus>({ state: \"loading\" });\n const [control, setControl] = useState<SandboxDesktopControl | undefined>(undefined);\n const [notice, setNotice] = useState<SandboxDesktopNotice | undefined>(undefined);\n\n // The viewer may send input unless it is read-only, or someone else holds control.\n const viewOnly =\n readOnly || control?.readOnly === true || (control?.held === true && !control.you);\n\n // The latest props, read by long-lived callbacks without restarting the connection.\n const latest = useRef({\n ticket,\n reconnect,\n onStatusChange,\n onControlChange,\n scaleToFit,\n viewOnly,\n quality: level(qualityLevel, defaultQualityLevel),\n compression: level(compressionLevel, defaultCompressionLevel),\n });\n latest.current = {\n ticket,\n reconnect,\n onStatusChange,\n onControlChange,\n scaleToFit,\n viewOnly,\n quality: level(qualityLevel, defaultQualityLevel),\n compression: level(compressionLevel, defaultCompressionLevel),\n };\n\n useImperativeHandle(\n ref,\n () => ({\n focus: () => viewer.current?.focus(),\n reconnect: () => connection.current?.retry(),\n takeOver: () => connection.current?.takeOver(),\n release: () => connection.current?.release(),\n }),\n [],\n );\n\n useEffect(() => {\n if (viewer.current !== undefined) viewer.current.viewOnly = viewOnly;\n }, [viewOnly]);\n useEffect(() => {\n if (viewer.current !== undefined) viewer.current.scaleViewport = scaleToFit;\n }, [scaleToFit]);\n useEffect(() => {\n if (viewer.current !== undefined) viewer.current.qualityLevel = latest.current.quality;\n }, [qualityLevel]);\n useEffect(() => {\n if (viewer.current !== undefined) {\n viewer.current.compressionLevel = latest.current.compression;\n }\n }, [compressionLevel]);\n\n useEffect(() => {\n const container = screen.current;\n if (container === null) return undefined;\n let disposed = false;\n let teardown: (() => void) | undefined;\n\n const publish = (next: RelayStatus) => {\n if (disposed) return;\n setStatus(next);\n // Who has control is unknown while the link is down.\n if (next.state !== \"open\") setControl(undefined);\n latest.current.onStatusChange?.(next);\n };\n publish({ state: \"loading\" });\n\n void (async () => {\n const { default: Rfb } = await import(\"@novnc/novnc\");\n if (disposed) return;\n\n const link = new DesktopConnection({\n url,\n getTicket: resolveTicket(() => latest.current.ticket),\n reconnect: latest.current.reconnect,\n onStatus: publish,\n onChannel: (channel) => {\n // noVNC clients are single-use: each attachment gets a new one.\n viewer.current?.disconnect();\n const rfb = new Rfb(container, channel);\n rfb.scaleViewport = latest.current.scaleToFit;\n rfb.clipViewport = false;\n // The desktop's size is set through the hub's API, never by a viewer.\n rfb.resizeSession = false;\n rfb.viewOnly = latest.current.viewOnly;\n // Set outright, not left to noVNC's defaults, so what a viewer asks of the server is\n // stated here and can be changed.\n rfb.qualityLevel = latest.current.quality;\n rfb.compressionLevel = latest.current.compression;\n viewer.current = rfb;\n },\n onControl: (next) => {\n if (disposed) return;\n setControl(next);\n setNotice(undefined);\n latest.current.onControlChange?.(next);\n },\n onNotice: (next) => {\n if (!disposed) setNotice(next);\n },\n });\n connection.current = link;\n link.start();\n teardown = () => {\n link.dispose();\n viewer.current?.disconnect();\n viewer.current = undefined;\n connection.current = undefined;\n };\n })().catch((error: unknown) => {\n publish({\n state: \"failed\",\n message: error instanceof Error ? error.message : \"Could not load the desktop viewer.\",\n });\n });\n\n return () => {\n disposed = true;\n teardown?.();\n };\n }, [url]);\n\n const retry = () => connection.current?.retry();\n const takeOver = () => connection.current?.takeOver();\n const release = () => connection.current?.release();\n const statusNode =\n renderStatus === undefined ? (\n <DefaultStatus status={status} retry={retry} />\n ) : (\n renderStatus(status, retry)\n );\n const controlsState: SandboxDesktopControlsState = {\n status,\n control,\n readOnly: readOnly || control?.readOnly === true,\n notice,\n takeOver,\n release,\n };\n const controlsNode =\n renderControls === undefined ? (\n <DefaultControls {...controlsState} />\n ) : (\n renderControls(controlsState)\n );\n\n return (\n <div\n {...rest}\n data-state={status.state}\n data-readonly={viewOnly ? \"\" : undefined}\n data-control={control?.you === true ? \"you\" : control?.held === true ? \"other\" : \"none\"}\n style={{ ...rootStyle, ...style }}\n >\n <div ref={screen} style={screenStyle} />\n {controlsNode}\n {statusNode}\n </div>\n );\n },\n);\n\nconst controlsStyle: CSSProperties = {\n position: \"absolute\",\n top: 8,\n left: 8,\n zIndex: 1,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n padding: \"2px 8px\",\n borderRadius: 4,\n fontSize: 12,\n background: \"rgba(0, 0, 0, 0.7)\",\n color: \"#fff\",\n};\n\nfunction DefaultControls({\n status,\n control,\n readOnly,\n notice,\n takeOver,\n release,\n}: SandboxDesktopControlsState) {\n if (status.state !== \"open\" || control === undefined) return null;\n const who = control.you\n ? \"You have control\"\n : control.held\n ? `${control.holder ?? \"Someone\"} has control`\n : readOnly\n ? \"View only\"\n : \"The agent has control\";\n return (\n <div data-sandbox-desktop-controls=\"\" style={controlsStyle}>\n <span>{who}</span>\n {control.you ? (\n <button type=\"button\" onClick={release}>\n Release\n </button>\n ) : !readOnly && !control.held ? (\n <button type=\"button\" onClick={takeOver}>\n Take over\n </button>\n ) : null}\n {notice === undefined ? null : <span role=\"alert\">{notice.message}</span>}\n </div>\n );\n}\n","/** What a {@link VncChannel} needs from the socket it wraps. */\nexport interface VncLink {\n send(data: ArrayBufferLike | ArrayBufferView): void;\n close(): void;\n}\n\n// WebSocket ready states, written out so this works wherever noVNC's own checks do.\nconst OPEN = 1;\nconst CLOSED = 3;\n\n/**\n * A WebSocket-shaped channel for noVNC to attach to. The hub's desktop relay mixes binary VNC frames\n * with JSON control frames on one socket, and noVNC understands only the former, so the link\n * hands this channel just the binary ones.\n *\n * The VNC server speaks first, so frames can arrive before noVNC has attached. They wait here\n * until it installs its `onmessage`.\n *\n * noVNC checks a channel for these property names (`send`, `close`, `binaryType`, `onerror`,\n * `onmessage`, `onopen`, `protocol`, `readyState`), as it does for a WebSocket or an\n * RTCDataChannel, so they must all be present.\n */\nexport class VncChannel {\n binaryType = \"arraybuffer\";\n protocol: string;\n onerror: ((event: unknown) => void) | null = null;\n onopen: ((event: unknown) => void) | null = null;\n onclose: ((event: unknown) => void) | null = null;\n private handler: ((event: { data: ArrayBuffer }) => void) | null = null;\n private backlog: ArrayBuffer[] = [];\n private state = OPEN;\n\n constructor(\n private readonly link: VncLink,\n protocol = \"\",\n ) {\n this.protocol = protocol;\n }\n\n get readyState(): number {\n return this.state;\n }\n\n get onmessage(): ((event: { data: ArrayBuffer }) => void) | null {\n return this.handler;\n }\n\n set onmessage(handler: ((event: { data: ArrayBuffer }) => void) | null) {\n this.handler = handler;\n if (handler === null) return;\n for (const data of this.backlog.splice(0)) handler({ data });\n }\n\n send(data: ArrayBufferLike | ArrayBufferView): void {\n if (this.state === OPEN) this.link.send(data);\n }\n\n /** noVNC is done with the connection. */\n close(): void {\n if (this.state !== OPEN) return;\n this.state = CLOSED;\n this.link.close();\n }\n\n /** A VNC frame arrived from the relay. */\n receive(data: ArrayBuffer): void {\n if (this.handler === null) this.backlog.push(data);\n else this.handler({ data });\n }\n\n /** The relay closed the socket. */\n closed(event: { code: number; reason: string }): void {\n if (this.state === CLOSED) return;\n this.state = CLOSED;\n this.backlog = [];\n this.onclose?.(event);\n }\n}\n","import {\n type RelayProtocol,\n type RelayReconnectOptions,\n type RelaySession,\n type RelayStatus,\n RelayConnection,\n} from \"../relay/connection\";\nimport { VncChannel } from \"./vnc-channel\";\n\n/** Subprotocol the hub's `handleDesktopUpgrade` selects. */\nexport const desktopSubprotocol = \"anvia.desktop.v1\";\n\n/** Who is driving the desktop, as the relay reports it to this viewer. */\nexport interface SandboxDesktopControl {\n /** Someone has taken control, which pauses the agent. */\n held: boolean;\n /** Who. Present when `held`. */\n holder?: string;\n /** When the hold lapses unless renewed. */\n expiresAt?: string;\n /** This viewer is the holder. */\n you: boolean;\n /** This viewer may only watch. */\n readOnly: boolean;\n}\n\nexport interface DesktopConnectionOptions {\n url: string;\n getTicket?: (() => string | Promise<string>) | undefined;\n reconnect?: RelayReconnectOptions | undefined;\n onStatus: (status: RelayStatus) => void;\n /** The relay accepted this attachment. Attach a VNC client to the channel. */\n onChannel: (channel: VncChannel, reconnected: boolean) => void;\n onControl: (control: SandboxDesktopControl) => void;\n /** A request over the control channel failed, such as taking over while someone else holds it. */\n onNotice?: ((notice: { code: string; message: string }) => void) | undefined;\n}\n\n/** Errors worth another attempt: the sandbox may simply not be reachable yet. */\nconst retryableCodes = new Set([\"sandbox_unavailable\", \"timeout\"]);\n/** The hub closes a socket it could not set up with `<error code>: <message>`. */\nconst closeReason = /^([a-z_]+): (.*)$/s;\n/** The relay closes with this code when the viewer sent something it could not account for. */\nconst policyViolation = 1008;\n\ntype ControlMessage = { type: \"control\" } & SandboxDesktopControl;\ntype ErrorMessage = { type: \"error\"; code?: string; message?: string };\n\n/**\n * One browser link to the hub's desktop relay, over a {@link RelayConnection}. It hands each\n * accepted attachment to the owner as a channel for a VNC client, and speaks the relay's small\n * JSON control protocol (`takeover`, `release`, and the `control` updates that come back).\n */\nexport class DesktopConnection {\n private readonly relay: RelayConnection;\n\n constructor(options: DesktopConnectionOptions) {\n let channel: VncChannel | undefined;\n let accepted = false;\n const protocol: RelayProtocol = {\n subprotocol: desktopSubprotocol,\n label: \"desktop\",\n open: (socket) => {\n accepted = false;\n channel = new VncChannel(\n { send: (data) => socket.send(data), close: () => socket.close(1000) },\n socket.protocol,\n );\n },\n message: (event, session: RelaySession) => {\n // The first thing the relay sends means it set the viewer up; a failure closes instead.\n if (!accepted && channel !== undefined) {\n accepted = true;\n options.onChannel(channel, session.ready());\n }\n if (typeof event.data !== \"string\") {\n channel?.receive(event.data as ArrayBuffer);\n return;\n }\n const message = parseText(event.data);\n if (message?.type === \"control\") {\n const { type: _type, ...control } = message;\n options.onControl(control);\n } else if (message?.type === \"error\") {\n options.onNotice?.({\n code: message.code ?? \"protocol\",\n message: message.message ?? \"The request failed.\",\n });\n }\n },\n close: (event, session) => {\n channel?.closed(event);\n const match = closeReason.exec(event.reason);\n if (match !== null && !retryableCodes.has(match[1] ?? \"\")) {\n session.fatal(match[2] ?? \"The desktop could not be opened.\");\n } else if (event.code === policyViolation) {\n session.fatal(event.reason || \"The desktop relay refused what the viewer sent.\");\n }\n },\n };\n this.relay = new RelayConnection({\n url: options.url,\n getTicket: options.getTicket,\n onStatus: options.onStatus,\n reconnect: options.reconnect,\n protocol,\n });\n }\n\n start(): void {\n this.relay.start();\n }\n\n retry(): void {\n this.relay.retry();\n }\n\n /** Ask for control, which pauses the agent's inputs. The answer arrives as a `control` update. */\n takeOver(): void {\n this.relay.sendJson({ type: \"takeover\" });\n }\n\n release(): void {\n this.relay.sendJson({ type: \"release\" });\n }\n\n dispose(): void {\n this.relay.dispose();\n }\n}\n\nfunction parseText(text: string): ControlMessage | ErrorMessage | undefined {\n try {\n const value: unknown = JSON.parse(text);\n if (typeof value !== \"object\" || value === null) return undefined;\n const type = (value as { type?: unknown }).type;\n return type === \"control\" || type === \"error\"\n ? (value as ControlMessage | ErrorMessage)\n : undefined;\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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","names":["SandboxDesktop"]}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { ITerminalOptions } from '@xterm/xterm';
|
|
4
|
+
import { P as PrimitiveProps } from '../render-CRKWam2v.js';
|
|
5
|
+
import { R as RelayReconnectOptions, a as RelayStatus, b as RelayState, T as TicketSource } from '../ticket-pexI1zIa.js';
|
|
6
|
+
export { t as ticketSubprotocolPrefix } from '../ticket-pexI1zIa.js';
|
|
7
|
+
|
|
8
|
+
/** Subprotocol the hub's `handleTerminalUpgrade` requires. */
|
|
9
|
+
declare const terminalSubprotocol = "anvia.terminal.v1";
|
|
10
|
+
type SandboxTerminalState = RelayState;
|
|
11
|
+
type SandboxTerminalStatus = RelayStatus;
|
|
12
|
+
type SandboxTerminalReconnectOptions = RelayReconnectOptions;
|
|
13
|
+
|
|
14
|
+
interface SandboxTerminalHandle {
|
|
15
|
+
focus(): void;
|
|
16
|
+
/** Reconnect now, after the terminal gave up. */
|
|
17
|
+
reconnect(): void;
|
|
18
|
+
}
|
|
19
|
+
type SandboxTerminalProps = Omit<PrimitiveProps<"div">, "children" | "onError"> & {
|
|
20
|
+
/**
|
|
21
|
+
* The hub's terminal endpoint (`hub.handleTerminalUpgrade`): `ws:`/`wss:`, `http:`/`https:`, or a
|
|
22
|
+
* path relative to this page.
|
|
23
|
+
*/
|
|
24
|
+
url: string;
|
|
25
|
+
/** Sent as `?session=` so your `authorizeTerminal` can choose the session. The server decides. */
|
|
26
|
+
sessionName?: string;
|
|
27
|
+
/**
|
|
28
|
+
* A one-time ticket, or a function returning one. Pass a function: tickets are single use, so
|
|
29
|
+
* each reconnect needs a fresh one. It travels as a WebSocket subprotocol, not in the URL.
|
|
30
|
+
*/
|
|
31
|
+
ticket?: TicketSource;
|
|
32
|
+
/** Show output only. The server enforces it too; this also stops the terminal taking input. */
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
reconnect?: SandboxTerminalReconnectOptions;
|
|
35
|
+
/** xterm options, such as `fontSize` or `theme`. Applied when the terminal is created. */
|
|
36
|
+
terminalOptions?: ITerminalOptions;
|
|
37
|
+
onStatusChange?: (status: SandboxTerminalStatus) => void;
|
|
38
|
+
/** Replace the default status line. Return null to show nothing. */
|
|
39
|
+
renderStatus?: (status: SandboxTerminalStatus, retry: () => void) => ReactNode;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* A terminal for a remote sandbox session, drawn with xterm.js. xterm is loaded on demand, so it
|
|
43
|
+
* costs nothing until a terminal mounts. Import `@xterm/xterm/css/xterm.css` once in your app.
|
|
44
|
+
*/
|
|
45
|
+
declare const SandboxTerminal: react.ForwardRefExoticComponent<Omit<PrimitiveProps<"div">, "children" | "onError"> & {
|
|
46
|
+
/**
|
|
47
|
+
* The hub's terminal endpoint (`hub.handleTerminalUpgrade`): `ws:`/`wss:`, `http:`/`https:`, or a
|
|
48
|
+
* path relative to this page.
|
|
49
|
+
*/
|
|
50
|
+
url: string;
|
|
51
|
+
/** Sent as `?session=` so your `authorizeTerminal` can choose the session. The server decides. */
|
|
52
|
+
sessionName?: string;
|
|
53
|
+
/**
|
|
54
|
+
* A one-time ticket, or a function returning one. Pass a function: tickets are single use, so
|
|
55
|
+
* each reconnect needs a fresh one. It travels as a WebSocket subprotocol, not in the URL.
|
|
56
|
+
*/
|
|
57
|
+
ticket?: TicketSource;
|
|
58
|
+
/** Show output only. The server enforces it too; this also stops the terminal taking input. */
|
|
59
|
+
readOnly?: boolean;
|
|
60
|
+
reconnect?: SandboxTerminalReconnectOptions;
|
|
61
|
+
/** xterm options, such as `fontSize` or `theme`. Applied when the terminal is created. */
|
|
62
|
+
terminalOptions?: ITerminalOptions;
|
|
63
|
+
onStatusChange?: (status: SandboxTerminalStatus) => void;
|
|
64
|
+
/** Replace the default status line. Return null to show nothing. */
|
|
65
|
+
renderStatus?: (status: SandboxTerminalStatus, retry: () => void) => ReactNode;
|
|
66
|
+
} & react.RefAttributes<SandboxTerminalHandle>>;
|
|
67
|
+
|
|
68
|
+
export { SandboxTerminal, type SandboxTerminalHandle, type SandboxTerminalProps, type SandboxTerminalReconnectOptions, type SandboxTerminalState, type SandboxTerminalStatus, terminalSubprotocol };
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DefaultStatus,
|
|
3
|
+
RelayConnection,
|
|
4
|
+
resolveTicket,
|
|
5
|
+
ticketSubprotocolPrefix
|
|
6
|
+
} from "../chunk-5FZKTDZR.js";
|
|
7
|
+
|
|
8
|
+
// src/sandbox-terminal/terminal.tsx
|
|
9
|
+
import {
|
|
10
|
+
forwardRef,
|
|
11
|
+
useEffect,
|
|
12
|
+
useImperativeHandle,
|
|
13
|
+
useRef,
|
|
14
|
+
useState
|
|
15
|
+
} from "react";
|
|
16
|
+
|
|
17
|
+
// src/sandbox-terminal/connection.ts
|
|
18
|
+
var terminalSubprotocol = "anvia.terminal.v1";
|
|
19
|
+
var retryableCodes = /* @__PURE__ */ new Set(["sandbox_unavailable", "timeout"]);
|
|
20
|
+
var TerminalConnection = class {
|
|
21
|
+
relay;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
let ready = false;
|
|
24
|
+
const protocol = {
|
|
25
|
+
subprotocol: terminalSubprotocol,
|
|
26
|
+
label: "terminal",
|
|
27
|
+
open: (socket) => {
|
|
28
|
+
ready = false;
|
|
29
|
+
const { cols, rows } = options.size();
|
|
30
|
+
socket.send(JSON.stringify({ type: "open", cols, rows }));
|
|
31
|
+
},
|
|
32
|
+
message: (event, session) => {
|
|
33
|
+
if (typeof event.data !== "string") {
|
|
34
|
+
if (ready) options.onOutput(new Uint8Array(event.data));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const message = parseMessage(event.data);
|
|
38
|
+
if (message === void 0) return;
|
|
39
|
+
if (message.type === "ready") {
|
|
40
|
+
ready = true;
|
|
41
|
+
options.onOpen(session.ready());
|
|
42
|
+
} else if (message.type === "exit") {
|
|
43
|
+
session.ended({ message: message.reason ?? "exited", exitCode: message.exitCode });
|
|
44
|
+
} else if (!retryableCodes.has(message.code ?? "")) {
|
|
45
|
+
session.fatal(message.message ?? "The terminal could not be opened.");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
this.relay = new RelayConnection({
|
|
50
|
+
url: options.url,
|
|
51
|
+
sessionName: options.sessionName,
|
|
52
|
+
getTicket: options.getTicket,
|
|
53
|
+
onStatus: options.onStatus,
|
|
54
|
+
reconnect: options.reconnect,
|
|
55
|
+
protocol
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
start() {
|
|
59
|
+
this.relay.start();
|
|
60
|
+
}
|
|
61
|
+
/** Try again now, for a link that gave up. */
|
|
62
|
+
retry() {
|
|
63
|
+
this.relay.retry();
|
|
64
|
+
}
|
|
65
|
+
sendInput(data) {
|
|
66
|
+
this.relay.sendJson({ type: "input", data });
|
|
67
|
+
}
|
|
68
|
+
sendResize(cols, rows) {
|
|
69
|
+
this.relay.sendJson({ type: "resize", cols, rows });
|
|
70
|
+
}
|
|
71
|
+
dispose() {
|
|
72
|
+
this.relay.dispose();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
function parseMessage(text) {
|
|
76
|
+
try {
|
|
77
|
+
const value = JSON.parse(text);
|
|
78
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
79
|
+
const type = value.type;
|
|
80
|
+
return type === "ready" || type === "exit" || type === "error" ? value : void 0;
|
|
81
|
+
} catch {
|
|
82
|
+
return void 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/sandbox-terminal/terminal.tsx
|
|
87
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
88
|
+
var rootStyle = { position: "relative", width: "100%", height: "100%" };
|
|
89
|
+
var screenStyle = { width: "100%", height: "100%" };
|
|
90
|
+
var SandboxTerminal = forwardRef(
|
|
91
|
+
function SandboxTerminal2({
|
|
92
|
+
url,
|
|
93
|
+
sessionName,
|
|
94
|
+
ticket,
|
|
95
|
+
readOnly = false,
|
|
96
|
+
reconnect,
|
|
97
|
+
terminalOptions,
|
|
98
|
+
onStatusChange,
|
|
99
|
+
renderStatus,
|
|
100
|
+
style,
|
|
101
|
+
...rest
|
|
102
|
+
}, ref) {
|
|
103
|
+
const screen = useRef(null);
|
|
104
|
+
const connection = useRef(void 0);
|
|
105
|
+
const terminal = useRef(void 0);
|
|
106
|
+
const [status, setStatus] = useState({ state: "loading" });
|
|
107
|
+
const latest = useRef({ ticket, reconnect, terminalOptions, onStatusChange });
|
|
108
|
+
latest.current = { ticket, reconnect, terminalOptions, onStatusChange };
|
|
109
|
+
useImperativeHandle(
|
|
110
|
+
ref,
|
|
111
|
+
() => ({
|
|
112
|
+
focus: () => terminal.current?.focus(),
|
|
113
|
+
reconnect: () => connection.current?.retry()
|
|
114
|
+
}),
|
|
115
|
+
[]
|
|
116
|
+
);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const container = screen.current;
|
|
119
|
+
if (container === null) return void 0;
|
|
120
|
+
let disposed = false;
|
|
121
|
+
let teardown;
|
|
122
|
+
const publish = (next) => {
|
|
123
|
+
if (disposed) return;
|
|
124
|
+
setStatus(next);
|
|
125
|
+
latest.current.onStatusChange?.(next);
|
|
126
|
+
};
|
|
127
|
+
publish({ state: "loading" });
|
|
128
|
+
void (async () => {
|
|
129
|
+
const [{ Terminal: XTerm }, { FitAddon }] = await Promise.all([
|
|
130
|
+
import("@xterm/xterm"),
|
|
131
|
+
import("@xterm/addon-fit")
|
|
132
|
+
]);
|
|
133
|
+
if (disposed) return;
|
|
134
|
+
const xterm = new XTerm({
|
|
135
|
+
cursorBlink: !readOnly,
|
|
136
|
+
disableStdin: readOnly,
|
|
137
|
+
scrollback: 5e3,
|
|
138
|
+
...latest.current.terminalOptions
|
|
139
|
+
});
|
|
140
|
+
const fit = new FitAddon();
|
|
141
|
+
xterm.loadAddon(fit);
|
|
142
|
+
xterm.open(container);
|
|
143
|
+
fit.fit();
|
|
144
|
+
terminal.current = xterm;
|
|
145
|
+
const link = new TerminalConnection({
|
|
146
|
+
url,
|
|
147
|
+
sessionName,
|
|
148
|
+
getTicket: resolveTicket(() => latest.current.ticket),
|
|
149
|
+
size: () => ({ cols: xterm.cols, rows: xterm.rows }),
|
|
150
|
+
onOutput: (bytes) => xterm.write(bytes),
|
|
151
|
+
// The hub redraws the session on every attach, so start from a clean screen.
|
|
152
|
+
onOpen: (reconnected) => {
|
|
153
|
+
if (reconnected) xterm.reset();
|
|
154
|
+
if (!readOnly) xterm.focus();
|
|
155
|
+
},
|
|
156
|
+
onStatus: publish,
|
|
157
|
+
reconnect: latest.current.reconnect
|
|
158
|
+
});
|
|
159
|
+
connection.current = link;
|
|
160
|
+
const input = xterm.onData((data) => {
|
|
161
|
+
if (!readOnly) link.sendInput(data);
|
|
162
|
+
});
|
|
163
|
+
const resized = xterm.onResize(({ cols, rows }) => {
|
|
164
|
+
if (!readOnly) link.sendResize(cols, rows);
|
|
165
|
+
});
|
|
166
|
+
let frame = 0;
|
|
167
|
+
const observer = new ResizeObserver(() => {
|
|
168
|
+
cancelAnimationFrame(frame);
|
|
169
|
+
frame = requestAnimationFrame(() => fit.fit());
|
|
170
|
+
});
|
|
171
|
+
observer.observe(container);
|
|
172
|
+
link.start();
|
|
173
|
+
teardown = () => {
|
|
174
|
+
cancelAnimationFrame(frame);
|
|
175
|
+
observer.disconnect();
|
|
176
|
+
input.dispose();
|
|
177
|
+
resized.dispose();
|
|
178
|
+
link.dispose();
|
|
179
|
+
xterm.dispose();
|
|
180
|
+
connection.current = void 0;
|
|
181
|
+
terminal.current = void 0;
|
|
182
|
+
};
|
|
183
|
+
})().catch((error) => {
|
|
184
|
+
publish({
|
|
185
|
+
state: "failed",
|
|
186
|
+
message: error instanceof Error ? error.message : "Could not load the terminal."
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
return () => {
|
|
190
|
+
disposed = true;
|
|
191
|
+
teardown?.();
|
|
192
|
+
};
|
|
193
|
+
}, [url, sessionName, readOnly]);
|
|
194
|
+
const retry = () => connection.current?.retry();
|
|
195
|
+
const statusNode = renderStatus === void 0 ? /* @__PURE__ */ jsx(DefaultStatus, { status, retry }) : renderStatus(status, retry);
|
|
196
|
+
return /* @__PURE__ */ jsxs(
|
|
197
|
+
"div",
|
|
198
|
+
{
|
|
199
|
+
...rest,
|
|
200
|
+
"data-state": status.state,
|
|
201
|
+
"data-readonly": readOnly ? "" : void 0,
|
|
202
|
+
style: { ...rootStyle, ...style },
|
|
203
|
+
children: [
|
|
204
|
+
/* @__PURE__ */ jsx("div", { ref: screen, style: screenStyle }),
|
|
205
|
+
statusNode
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
export {
|
|
212
|
+
SandboxTerminal,
|
|
213
|
+
terminalSubprotocol,
|
|
214
|
+
ticketSubprotocolPrefix
|
|
215
|
+
};
|
|
216
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/sandbox-terminal/terminal.tsx","../../src/sandbox-terminal/connection.ts"],"sourcesContent":["import type { ITerminalOptions, Terminal } from \"@xterm/xterm\";\nimport {\n forwardRef,\n useEffect,\n useImperativeHandle,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\n\nimport { type PrimitiveProps } from \"../primitives\";\nimport { DefaultStatus } from \"../relay/status\";\nimport { type TicketSource, resolveTicket } from \"../relay/ticket\";\nimport {\n type SandboxTerminalReconnectOptions,\n type SandboxTerminalStatus,\n TerminalConnection,\n} from \"./connection\";\n\nexport interface SandboxTerminalHandle {\n focus(): void;\n /** Reconnect now, after the terminal gave up. */\n reconnect(): void;\n}\n\nexport type SandboxTerminalProps = Omit<PrimitiveProps<\"div\">, \"children\" | \"onError\"> & {\n /**\n * The hub's terminal endpoint (`hub.handleTerminalUpgrade`): `ws:`/`wss:`, `http:`/`https:`, or a\n * path relative to this page.\n */\n url: string;\n /** Sent as `?session=` so your `authorizeTerminal` can choose the session. The server decides. */\n sessionName?: string;\n /**\n * A one-time ticket, or a function returning one. Pass a function: tickets are single use, so\n * each reconnect needs a fresh one. It travels as a WebSocket subprotocol, not in the URL.\n */\n ticket?: TicketSource;\n /** Show output only. The server enforces it too; this also stops the terminal taking input. */\n readOnly?: boolean;\n reconnect?: SandboxTerminalReconnectOptions;\n /** xterm options, such as `fontSize` or `theme`. Applied when the terminal is created. */\n terminalOptions?: ITerminalOptions;\n onStatusChange?: (status: SandboxTerminalStatus) => void;\n /** Replace the default status line. Return null to show nothing. */\n renderStatus?: (status: SandboxTerminalStatus, retry: () => void) => ReactNode;\n};\n\nconst rootStyle: CSSProperties = { position: \"relative\", width: \"100%\", height: \"100%\" };\nconst screenStyle: CSSProperties = { width: \"100%\", height: \"100%\" };\n\n/**\n * A terminal for a remote sandbox session, drawn with xterm.js. xterm is loaded on demand, so it\n * costs nothing until a terminal mounts. Import `@xterm/xterm/css/xterm.css` once in your app.\n */\nexport const SandboxTerminal = forwardRef<SandboxTerminalHandle, SandboxTerminalProps>(\n function SandboxTerminal(\n {\n url,\n sessionName,\n ticket,\n readOnly = false,\n reconnect,\n terminalOptions,\n onStatusChange,\n renderStatus,\n style,\n ...rest\n },\n ref,\n ) {\n const screen = useRef<HTMLDivElement>(null);\n const connection = useRef<TerminalConnection | undefined>(undefined);\n const terminal = useRef<Terminal | undefined>(undefined);\n const [status, setStatus] = useState<SandboxTerminalStatus>({ state: \"loading\" });\n\n // The latest props, read by long-lived callbacks without restarting the terminal.\n const latest = useRef({ ticket, reconnect, terminalOptions, onStatusChange });\n latest.current = { ticket, reconnect, terminalOptions, onStatusChange };\n\n useImperativeHandle(\n ref,\n () => ({\n focus: () => terminal.current?.focus(),\n reconnect: () => connection.current?.retry(),\n }),\n [],\n );\n\n useEffect(() => {\n const container = screen.current;\n if (container === null) return undefined;\n let disposed = false;\n let teardown: (() => void) | undefined;\n\n const publish = (next: SandboxTerminalStatus) => {\n if (disposed) return;\n setStatus(next);\n latest.current.onStatusChange?.(next);\n };\n publish({ state: \"loading\" });\n\n void (async () => {\n const [{ Terminal: XTerm }, { FitAddon }] = await Promise.all([\n import(\"@xterm/xterm\"),\n import(\"@xterm/addon-fit\"),\n ]);\n if (disposed) return;\n\n const xterm = new XTerm({\n cursorBlink: !readOnly,\n disableStdin: readOnly,\n scrollback: 5_000,\n ...latest.current.terminalOptions,\n });\n const fit = new FitAddon();\n xterm.loadAddon(fit);\n xterm.open(container);\n fit.fit();\n terminal.current = xterm;\n\n const link = new TerminalConnection({\n url,\n sessionName,\n getTicket: resolveTicket(() => latest.current.ticket),\n size: () => ({ cols: xterm.cols, rows: xterm.rows }),\n onOutput: (bytes) => xterm.write(bytes),\n // The hub redraws the session on every attach, so start from a clean screen.\n onOpen: (reconnected) => {\n if (reconnected) xterm.reset();\n if (!readOnly) xterm.focus();\n },\n onStatus: publish,\n reconnect: latest.current.reconnect,\n });\n connection.current = link;\n\n const input = xterm.onData((data) => {\n if (!readOnly) link.sendInput(data);\n });\n const resized = xterm.onResize(({ cols, rows }) => {\n if (!readOnly) link.sendResize(cols, rows);\n });\n let frame = 0;\n const observer = new ResizeObserver(() => {\n cancelAnimationFrame(frame);\n frame = requestAnimationFrame(() => fit.fit());\n });\n observer.observe(container);\n\n link.start();\n teardown = () => {\n cancelAnimationFrame(frame);\n observer.disconnect();\n input.dispose();\n resized.dispose();\n link.dispose();\n xterm.dispose();\n connection.current = undefined;\n terminal.current = undefined;\n };\n })().catch((error: unknown) => {\n publish({\n state: \"failed\",\n message: error instanceof Error ? error.message : \"Could not load the terminal.\",\n });\n });\n\n return () => {\n disposed = true;\n teardown?.();\n };\n }, [url, sessionName, readOnly]);\n\n const retry = () => connection.current?.retry();\n const statusNode =\n renderStatus === undefined ? (\n <DefaultStatus status={status} retry={retry} />\n ) : (\n renderStatus(status, retry)\n );\n\n return (\n <div\n {...rest}\n data-state={status.state}\n data-readonly={readOnly ? \"\" : undefined}\n style={{ ...rootStyle, ...style }}\n >\n <div ref={screen} style={screenStyle} />\n {statusNode}\n </div>\n );\n },\n);\n","import {\n type RelayProtocol,\n type RelayReconnectOptions,\n type RelaySession,\n type RelayState,\n type RelayStatus,\n RelayConnection,\n} from \"../relay/connection\";\n\nexport { ticketSubprotocolPrefix } from \"../relay/connection\";\n\n/** Subprotocol the hub's `handleTerminalUpgrade` requires. */\nexport const terminalSubprotocol = \"anvia.terminal.v1\";\n\nexport type SandboxTerminalState = RelayState;\nexport type SandboxTerminalStatus = RelayStatus;\nexport type SandboxTerminalReconnectOptions = RelayReconnectOptions;\n\nexport interface TerminalConnectionOptions {\n /** `ws:`, `wss:`, `http:`, `https:`, or a path relative to the page. */\n url: string;\n /** Appended as `?session=` so the server can pick the session. The server stays in charge. */\n sessionName?: string | undefined;\n /** Called for every attempt, because tickets are single use. */\n getTicket?: (() => string | Promise<string>) | undefined;\n size: () => { cols: number; rows: number };\n onOutput: (bytes: Uint8Array) => void;\n /** The terminal is attached. `reconnected` is true when an earlier attachment existed. */\n onOpen: (reconnected: boolean) => void;\n onStatus: (status: SandboxTerminalStatus) => void;\n reconnect?: SandboxTerminalReconnectOptions | undefined;\n}\n\n/** Errors worth another attempt: the sandbox may simply not be reachable yet. */\nconst retryableCodes = new Set([\"sandbox_unavailable\", \"timeout\"]);\n\ntype ServerMessage =\n | { type: \"ready\"; readOnly?: boolean }\n | { type: \"exit\"; reason?: string; exitCode?: number }\n | { type: \"error\"; code?: string; message?: string };\n\n/**\n * One browser terminal link: speaks the hub's terminal relay protocol over a {@link\n * RelayConnection}. It knows nothing about xterm, so it is testable on its own.\n */\nexport class TerminalConnection {\n private readonly relay: RelayConnection;\n\n constructor(options: TerminalConnectionOptions) {\n // The relay only sends output once it has opened the terminal, which starts with our size.\n let ready = false;\n const protocol: RelayProtocol = {\n subprotocol: terminalSubprotocol,\n label: \"terminal\",\n open: (socket) => {\n ready = false;\n const { cols, rows } = options.size();\n socket.send(JSON.stringify({ type: \"open\", cols, rows }));\n },\n message: (event, session: RelaySession) => {\n if (typeof event.data !== \"string\") {\n if (ready) options.onOutput(new Uint8Array(event.data as ArrayBuffer));\n return;\n }\n const message = parseMessage(event.data);\n if (message === undefined) return;\n if (message.type === \"ready\") {\n ready = true;\n options.onOpen(session.ready());\n } else if (message.type === \"exit\") {\n session.ended({ message: message.reason ?? \"exited\", exitCode: message.exitCode });\n } else if (!retryableCodes.has(message.code ?? \"\")) {\n session.fatal(message.message ?? \"The terminal could not be opened.\");\n }\n },\n };\n this.relay = new RelayConnection({\n url: options.url,\n sessionName: options.sessionName,\n getTicket: options.getTicket,\n onStatus: options.onStatus,\n reconnect: options.reconnect,\n protocol,\n });\n }\n\n start(): void {\n this.relay.start();\n }\n\n /** Try again now, for a link that gave up. */\n retry(): void {\n this.relay.retry();\n }\n\n sendInput(data: string): void {\n this.relay.sendJson({ type: \"input\", data });\n }\n\n sendResize(cols: number, rows: number): void {\n this.relay.sendJson({ type: \"resize\", cols, rows });\n }\n\n dispose(): void {\n this.relay.dispose();\n }\n}\n\nfunction parseMessage(text: string): ServerMessage | undefined {\n try {\n const value: unknown = JSON.parse(text);\n if (typeof value !== \"object\" || value === null) return undefined;\n const type = (value as { type?: unknown }).type;\n return type === \"ready\" || type === \"exit\" || type === \"error\"\n ? (value as ServerMessage)\n : undefined;\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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","names":["SandboxTerminal"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The prefix a ticket travels under in `Sec-WebSocket-Protocol`. */
|
|
2
|
+
declare const ticketSubprotocolPrefix = "anvia.ticket.";
|
|
3
|
+
type RelayState =
|
|
4
|
+
/** The viewer library is still loading. */
|
|
5
|
+
"loading" | "connecting" | "open"
|
|
6
|
+
/** The link dropped and another attempt is scheduled. */
|
|
7
|
+
| "reconnecting"
|
|
8
|
+
/** The session itself ended; reconnecting would not help. */
|
|
9
|
+
| "ended"
|
|
10
|
+
/** Gave up: a fatal error, or reconnect attempts ran out. */
|
|
11
|
+
| "failed";
|
|
12
|
+
interface RelayStatus {
|
|
13
|
+
state: RelayState;
|
|
14
|
+
/** Reconnect attempt number while `reconnecting`. */
|
|
15
|
+
attempt?: number;
|
|
16
|
+
/** Delay before the next attempt while `reconnecting`. */
|
|
17
|
+
retryInMs?: number;
|
|
18
|
+
/** Exit code when the session ended with one. */
|
|
19
|
+
exitCode?: number;
|
|
20
|
+
/** Human-readable cause for `ended` and `failed`. */
|
|
21
|
+
message?: string;
|
|
22
|
+
}
|
|
23
|
+
interface RelayReconnectOptions {
|
|
24
|
+
initialDelayMs?: number;
|
|
25
|
+
maxDelayMs?: number;
|
|
26
|
+
/** Consecutive failed attempts before giving up. Default 10. */
|
|
27
|
+
maxAttempts?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a viewer's `ticket` prop can be: a ticket, or a function returning one per connection. */
|
|
31
|
+
type TicketSource = string | (() => string | Promise<string>);
|
|
32
|
+
|
|
33
|
+
export { type RelayReconnectOptions as R, type TicketSource as T, type RelayStatus as a, type RelayState as b, ticketSubprotocolPrefix as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anvia/react-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Composable, headless React UI primitives for Anvia applications.",
|
|
5
5
|
"author": "anvia",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,6 +51,14 @@
|
|
|
51
51
|
"types": "./dist/message/index.d.ts",
|
|
52
52
|
"import": "./dist/message/index.js"
|
|
53
53
|
},
|
|
54
|
+
"./sandbox-terminal": {
|
|
55
|
+
"types": "./dist/sandbox-terminal/index.d.ts",
|
|
56
|
+
"import": "./dist/sandbox-terminal/index.js"
|
|
57
|
+
},
|
|
58
|
+
"./sandbox-desktop": {
|
|
59
|
+
"types": "./dist/sandbox-desktop/index.d.ts",
|
|
60
|
+
"import": "./dist/sandbox-desktop/index.js"
|
|
61
|
+
},
|
|
54
62
|
"./selection-toolbar": {
|
|
55
63
|
"types": "./dist/selection-toolbar/index.d.ts",
|
|
56
64
|
"import": "./dist/selection-toolbar/index.js"
|
|
@@ -87,33 +95,48 @@
|
|
|
87
95
|
"peerDependencies": {
|
|
88
96
|
"react": ">=18",
|
|
89
97
|
"react-dom": ">=18",
|
|
90
|
-
"@
|
|
98
|
+
"@xterm/xterm": ">=5.5.0",
|
|
99
|
+
"@xterm/addon-fit": ">=0.10.0",
|
|
100
|
+
"@novnc/novnc": ">=1.5.0",
|
|
91
101
|
"@anvia/client": "^1.6.0",
|
|
102
|
+
"@anvia/graph": "^1.1.5",
|
|
92
103
|
"@anvia/react": "^1.1.5"
|
|
93
104
|
},
|
|
94
105
|
"peerDependenciesMeta": {
|
|
95
106
|
"@anvia/graph": {
|
|
96
107
|
"optional": true
|
|
108
|
+
},
|
|
109
|
+
"@xterm/xterm": {
|
|
110
|
+
"optional": true
|
|
111
|
+
},
|
|
112
|
+
"@xterm/addon-fit": {
|
|
113
|
+
"optional": true
|
|
114
|
+
},
|
|
115
|
+
"@novnc/novnc": {
|
|
116
|
+
"optional": true
|
|
97
117
|
}
|
|
98
118
|
},
|
|
99
119
|
"devDependencies": {
|
|
120
|
+
"@novnc/novnc": "^1.7.0",
|
|
100
121
|
"@testing-library/react": "^16.3.3",
|
|
101
122
|
"@types/node": "^24.9.1",
|
|
102
123
|
"@types/react": "^19.3.0",
|
|
103
124
|
"@types/react-dom": "^19.3.0",
|
|
104
125
|
"@vitest/coverage-v8": "4.1.11",
|
|
126
|
+
"@xterm/addon-fit": "^0.10.0",
|
|
127
|
+
"@xterm/xterm": "^5.5.0",
|
|
105
128
|
"happy-dom": "20.9.0",
|
|
106
129
|
"react": "19.3.0",
|
|
107
130
|
"react-dom": "19.3.0",
|
|
108
131
|
"tsup": "^8.5.0",
|
|
109
132
|
"typescript": "^5.9.3",
|
|
110
133
|
"vitest": "^4.1.11",
|
|
111
|
-
"@anvia/client": "1.6.0",
|
|
112
134
|
"@anvia/react": "1.1.5",
|
|
135
|
+
"@anvia/client": "1.6.0",
|
|
113
136
|
"@anvia/graph": "1.1.5"
|
|
114
137
|
},
|
|
115
138
|
"scripts": {
|
|
116
|
-
"build": "tsup src/index.ts src/attachment/index.ts src/chat/index.ts src/completion/index.ts src/graph-explorer/index.ts src/human-input/index.ts src/image/index.ts src/message/index.ts src/selection-toolbar/index.ts src/shared.ts src/stream/index.ts src/thread-list/index.ts --format esm --dts --sourcemap --clean --external react --external react-dom",
|
|
139
|
+
"build": "tsup src/index.ts src/attachment/index.ts src/chat/index.ts src/completion/index.ts src/graph-explorer/index.ts src/human-input/index.ts src/image/index.ts src/message/index.ts src/sandbox-desktop/index.ts src/sandbox-terminal/index.ts src/selection-toolbar/index.ts src/shared.ts src/stream/index.ts src/thread-list/index.ts --format esm --dts --sourcemap --clean --external react --external react-dom --external @xterm/xterm --external @xterm/addon-fit --external @novnc/novnc",
|
|
117
140
|
"coverage": "vitest run --coverage",
|
|
118
141
|
"test": "vitest run",
|
|
119
142
|
"typecheck": "tsc --noEmit"
|