@heyocomputer/hws 0.0.0-stage → 0.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/CHANGELOG.md +55 -0
- package/README.md +238 -2
- package/dist/client.d.ts +371 -0
- package/dist/client.js +761 -0
- package/dist/errors.d.ts +113 -0
- package/dist/errors.js +228 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +48 -0
- package/dist/obs.d.ts +86 -0
- package/dist/obs.js +115 -0
- package/dist/shell.d.ts +123 -0
- package/dist/shell.js +340 -0
- package/dist/types.d.ts +1312 -0
- package/dist/types.js +13 -0
- package/dist/wait.d.ts +70 -0
- package/dist/wait.js +99 -0
- package/package.json +58 -4
package/dist/shell.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An interactive PTY in a sandbox, over a WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* # The protocol
|
|
5
|
+
*
|
|
6
|
+
* app-lb terminates the daemon's own shell protocol and re-presents a much
|
|
7
|
+
* smaller one — no sequence numbers, no acks, no `init` frame, no session id:
|
|
8
|
+
*
|
|
9
|
+
* ```text
|
|
10
|
+
* client → server binary [0x01, ...stdin]
|
|
11
|
+
* client → server text {"type":"resize","cols":N,"rows":N}
|
|
12
|
+
* server → client text {"type":"ready","sandbox_id":"…"} (once, first)
|
|
13
|
+
* server → client binary [0x02, ...stdout] (PTY merges stderr)
|
|
14
|
+
* server → client text {"type":"exit","code":N}
|
|
15
|
+
* server → client text {"type":"error","message":"…"} (non-terminal)
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* {@link Shell} owns that encoding so no caller meets `0x01`. **That prefix is
|
|
19
|
+
* mandatory** — app-lb silently drops a binary frame starting with anything
|
|
20
|
+
* else, no error and no diagnosis, which is the easiest way to write a shell
|
|
21
|
+
* client that connects perfectly and types nothing.
|
|
22
|
+
*
|
|
23
|
+
* # Three things that are not smoothed over
|
|
24
|
+
*
|
|
25
|
+
* **`exit` code 0 is ambiguous.** It means both "exited cleanly" and "the VM
|
|
26
|
+
* died": on reconnect exhaustion app-lb forwards an `error` and then closes with
|
|
27
|
+
* `exit: 0`. So {@link ShellExit} carries any error that preceded it and
|
|
28
|
+
* `clean` is false when one did.
|
|
29
|
+
*
|
|
30
|
+
* **There is no resume.** A dropped socket means the session is gone;
|
|
31
|
+
* reconnecting gives a *new* shell. This package will not silently retry.
|
|
32
|
+
*
|
|
33
|
+
* **Nothing pings but us.** app-lb never originates one, so an idle socket is
|
|
34
|
+
* free for any intermediary to reap. Node pings every {@link PING_INTERVAL_MS};
|
|
35
|
+
* browsers cannot send pings, so the runtime's own keepalive applies.
|
|
36
|
+
*/
|
|
37
|
+
import type { Heyctl } from "./client.js";
|
|
38
|
+
export declare const PING_INTERVAL_MS = 25000;
|
|
39
|
+
export interface ShellOptions {
|
|
40
|
+
cols?: number;
|
|
41
|
+
rows?: number;
|
|
42
|
+
cwd?: string;
|
|
43
|
+
/** Boot or resume a VM if none is running. Default true. */
|
|
44
|
+
wake?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Open the shell in this VM of the deployment rather than whichever the
|
|
47
|
+
* pool offers. Nothing is woken when a VM is named: one that is not in the
|
|
48
|
+
* deployment is a 404, one that cannot be used yet a 409.
|
|
49
|
+
*/
|
|
50
|
+
sandboxId?: string;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}
|
|
53
|
+
export interface ShellExit {
|
|
54
|
+
/**
|
|
55
|
+
* The guest's exit code. **`0` alone does not mean success** — app-lb reports
|
|
56
|
+
* an *unknown* code as 0, which is what a VM dying under a live session looks
|
|
57
|
+
* like. Check {@link clean}.
|
|
58
|
+
*/
|
|
59
|
+
code: number;
|
|
60
|
+
/** The last error before the session ended, if any. */
|
|
61
|
+
error?: string;
|
|
62
|
+
/** Exited zero *and* nothing went wrong on the way. */
|
|
63
|
+
clean: boolean;
|
|
64
|
+
}
|
|
65
|
+
/** Build the WebSocket URL, swapping the scheme. */
|
|
66
|
+
export declare function shellUrl(server: string, id: string, query: string): string;
|
|
67
|
+
/** The query string for a set of options. */
|
|
68
|
+
export declare function shellQuery(opts: ShellOptions): string;
|
|
69
|
+
/** Frame stdin. The `0x01` is not optional. */
|
|
70
|
+
export declare function frameStdin(data: Uint8Array): Uint8Array;
|
|
71
|
+
/** What one incoming frame meant. Exported so it can be tested without a socket. */
|
|
72
|
+
export type Incoming = {
|
|
73
|
+
type: "ready";
|
|
74
|
+
sandboxId: string;
|
|
75
|
+
} | {
|
|
76
|
+
type: "output";
|
|
77
|
+
data: Uint8Array;
|
|
78
|
+
} | {
|
|
79
|
+
type: "error";
|
|
80
|
+
message: string;
|
|
81
|
+
} | {
|
|
82
|
+
type: "exit";
|
|
83
|
+
code: number;
|
|
84
|
+
} | {
|
|
85
|
+
type: "ignored";
|
|
86
|
+
};
|
|
87
|
+
export declare function parseIncoming(raw: string | Uint8Array): Incoming;
|
|
88
|
+
/** Whether this runtime can set headers on a WebSocket upgrade. */
|
|
89
|
+
export declare function canSetWsHeaders(): boolean;
|
|
90
|
+
/** A live session. */
|
|
91
|
+
export declare class Shell {
|
|
92
|
+
private readonly socket;
|
|
93
|
+
private readonly listeners;
|
|
94
|
+
private readonly errorListeners;
|
|
95
|
+
private lastError?;
|
|
96
|
+
private settled;
|
|
97
|
+
private resolveExit;
|
|
98
|
+
private pingTimer?;
|
|
99
|
+
/** Resolves when the session ends. */
|
|
100
|
+
readonly exit: Promise<ShellExit>;
|
|
101
|
+
readonly sandboxId: string;
|
|
102
|
+
private constructor();
|
|
103
|
+
/** @internal */
|
|
104
|
+
static open(client: Heyctl, id: string, opts: ShellOptions): Promise<Shell>;
|
|
105
|
+
private start;
|
|
106
|
+
private finish;
|
|
107
|
+
/** Called for every chunk the PTY produces. stderr is already merged in. */
|
|
108
|
+
onData(fn: (data: Uint8Array) => void): void;
|
|
109
|
+
/** Called for a non-fatal error. The session continues. */
|
|
110
|
+
onError(fn: (message: string) => void): void;
|
|
111
|
+
/** Send stdin. */
|
|
112
|
+
write(data: Uint8Array | string): void;
|
|
113
|
+
/**
|
|
114
|
+
* Tell the guest its terminal changed size.
|
|
115
|
+
*
|
|
116
|
+
* Worth wiring to `process.stdout.on("resize")` — app-lb has always supported
|
|
117
|
+
* this and no client used it, so resizing a terminal mid-session left the
|
|
118
|
+
* guest PTY at whatever geometry it started with.
|
|
119
|
+
*/
|
|
120
|
+
resize(cols: number, rows: number): void;
|
|
121
|
+
/** Close the session and wait for it to settle. */
|
|
122
|
+
close(): Promise<ShellExit>;
|
|
123
|
+
}
|
package/dist/shell.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An interactive PTY in a sandbox, over a WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* # The protocol
|
|
5
|
+
*
|
|
6
|
+
* app-lb terminates the daemon's own shell protocol and re-presents a much
|
|
7
|
+
* smaller one — no sequence numbers, no acks, no `init` frame, no session id:
|
|
8
|
+
*
|
|
9
|
+
* ```text
|
|
10
|
+
* client → server binary [0x01, ...stdin]
|
|
11
|
+
* client → server text {"type":"resize","cols":N,"rows":N}
|
|
12
|
+
* server → client text {"type":"ready","sandbox_id":"…"} (once, first)
|
|
13
|
+
* server → client binary [0x02, ...stdout] (PTY merges stderr)
|
|
14
|
+
* server → client text {"type":"exit","code":N}
|
|
15
|
+
* server → client text {"type":"error","message":"…"} (non-terminal)
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* {@link Shell} owns that encoding so no caller meets `0x01`. **That prefix is
|
|
19
|
+
* mandatory** — app-lb silently drops a binary frame starting with anything
|
|
20
|
+
* else, no error and no diagnosis, which is the easiest way to write a shell
|
|
21
|
+
* client that connects perfectly and types nothing.
|
|
22
|
+
*
|
|
23
|
+
* # Three things that are not smoothed over
|
|
24
|
+
*
|
|
25
|
+
* **`exit` code 0 is ambiguous.** It means both "exited cleanly" and "the VM
|
|
26
|
+
* died": on reconnect exhaustion app-lb forwards an `error` and then closes with
|
|
27
|
+
* `exit: 0`. So {@link ShellExit} carries any error that preceded it and
|
|
28
|
+
* `clean` is false when one did.
|
|
29
|
+
*
|
|
30
|
+
* **There is no resume.** A dropped socket means the session is gone;
|
|
31
|
+
* reconnecting gives a *new* shell. This package will not silently retry.
|
|
32
|
+
*
|
|
33
|
+
* **Nothing pings but us.** app-lb never originates one, so an idle socket is
|
|
34
|
+
* free for any intermediary to reap. Node pings every {@link PING_INTERVAL_MS};
|
|
35
|
+
* browsers cannot send pings, so the runtime's own keepalive applies.
|
|
36
|
+
*/
|
|
37
|
+
import { ShellError, fromResponse } from "./errors.js";
|
|
38
|
+
const STDIN = 0x01;
|
|
39
|
+
const STDOUT = 0x02;
|
|
40
|
+
export const PING_INTERVAL_MS = 25_000;
|
|
41
|
+
/** Build the WebSocket URL, swapping the scheme. */
|
|
42
|
+
export function shellUrl(server, id, query) {
|
|
43
|
+
const swapped = server.startsWith("https://")
|
|
44
|
+
? `wss://${server.slice(8)}`
|
|
45
|
+
: server.startsWith("http://")
|
|
46
|
+
? `ws://${server.slice(7)}`
|
|
47
|
+
: `ws://${server}`;
|
|
48
|
+
return `${swapped}/deployments/${encodeURIComponent(id)}/shell?${query}`;
|
|
49
|
+
}
|
|
50
|
+
/** The query string for a set of options. */
|
|
51
|
+
export function shellQuery(opts) {
|
|
52
|
+
const parts = [
|
|
53
|
+
`cols=${opts.cols ?? 80}`,
|
|
54
|
+
`rows=${opts.rows ?? 24}`,
|
|
55
|
+
// Only `true`/`false` parse server-side.
|
|
56
|
+
`wake=${(opts.wake ?? true) ? "true" : "false"}`,
|
|
57
|
+
];
|
|
58
|
+
if (opts.cwd)
|
|
59
|
+
parts.push(`cwd=${encodeURIComponent(opts.cwd)}`);
|
|
60
|
+
if (opts.sandboxId)
|
|
61
|
+
parts.push(`sandbox_id=${encodeURIComponent(opts.sandboxId)}`);
|
|
62
|
+
return parts.join("&");
|
|
63
|
+
}
|
|
64
|
+
/** Frame stdin. The `0x01` is not optional. */
|
|
65
|
+
export function frameStdin(data) {
|
|
66
|
+
const out = new Uint8Array(data.length + 1);
|
|
67
|
+
out[0] = STDIN;
|
|
68
|
+
out.set(data, 1);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
export function parseIncoming(raw) {
|
|
72
|
+
if (typeof raw !== "string") {
|
|
73
|
+
// app-lb only ever sends 0x02; anything else is a newer channel or a
|
|
74
|
+
// corrupted frame, and either way not output.
|
|
75
|
+
if (raw.length === 0 || raw[0] !== STDOUT)
|
|
76
|
+
return { type: "ignored" };
|
|
77
|
+
return { type: "output", data: raw.subarray(1) };
|
|
78
|
+
}
|
|
79
|
+
let v;
|
|
80
|
+
try {
|
|
81
|
+
v = JSON.parse(raw);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { type: "ignored" };
|
|
85
|
+
}
|
|
86
|
+
switch (v?.type) {
|
|
87
|
+
case "ready":
|
|
88
|
+
return { type: "ready", sandboxId: typeof v.sandbox_id === "string" ? v.sandbox_id : "" };
|
|
89
|
+
case "exit":
|
|
90
|
+
return { type: "exit", code: typeof v.code === "number" ? v.code : 0 };
|
|
91
|
+
case "error":
|
|
92
|
+
return {
|
|
93
|
+
type: "error",
|
|
94
|
+
message: typeof v.message === "string" && v.message
|
|
95
|
+
? v.message
|
|
96
|
+
: "the server reported an error with no message",
|
|
97
|
+
};
|
|
98
|
+
default:
|
|
99
|
+
// A newer app-lb adding a frame type must not break an older client.
|
|
100
|
+
return { type: "ignored" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Whether this runtime can set headers on a WebSocket upgrade. */
|
|
104
|
+
export function canSetWsHeaders() {
|
|
105
|
+
// Browsers cannot, and that is the entire reason `?app_token=` exists.
|
|
106
|
+
return typeof globalThis.window === "undefined";
|
|
107
|
+
}
|
|
108
|
+
/** A live session. */
|
|
109
|
+
export class Shell {
|
|
110
|
+
socket;
|
|
111
|
+
listeners = [];
|
|
112
|
+
errorListeners = [];
|
|
113
|
+
lastError;
|
|
114
|
+
settled = false;
|
|
115
|
+
resolveExit;
|
|
116
|
+
pingTimer;
|
|
117
|
+
/** Resolves when the session ends. */
|
|
118
|
+
exit;
|
|
119
|
+
sandboxId;
|
|
120
|
+
constructor(socket, sandboxId) {
|
|
121
|
+
this.socket = socket;
|
|
122
|
+
this.sandboxId = sandboxId;
|
|
123
|
+
this.exit = new Promise((resolve) => {
|
|
124
|
+
this.resolveExit = resolve;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** @internal */
|
|
128
|
+
static async open(client, id, opts) {
|
|
129
|
+
const header = client.authHeader();
|
|
130
|
+
let query = shellQuery(opts);
|
|
131
|
+
// A browser's WebSocket constructor cannot set headers, so a token in the
|
|
132
|
+
// query string is the only credential it can carry. Header everywhere it is
|
|
133
|
+
// possible — a credential in a URL lands in access logs, proxy logs and
|
|
134
|
+
// browser history.
|
|
135
|
+
const useQueryToken = !canSetWsHeaders() && client.auth.kind === "token";
|
|
136
|
+
if (useQueryToken) {
|
|
137
|
+
query += `&app_token=${encodeURIComponent(client.auth.token)}`;
|
|
138
|
+
}
|
|
139
|
+
else if (!canSetWsHeaders() && client.auth.kind === "basic") {
|
|
140
|
+
throw new ShellError("a browser cannot send Basic credentials on a WebSocket upgrade — use an " +
|
|
141
|
+
"app-token instead, ideally a short-lived one, since it travels in the URL");
|
|
142
|
+
}
|
|
143
|
+
const url = shellUrl(client.server, id, query);
|
|
144
|
+
const socket = await connect(url, useQueryToken ? undefined : header, id, client);
|
|
145
|
+
// The first frame is always `ready`; read it here so `sandboxId` is known
|
|
146
|
+
// before the caller sees a byte of output.
|
|
147
|
+
const sandboxId = await new Promise((resolve, reject) => {
|
|
148
|
+
const onMessage = (data) => {
|
|
149
|
+
const msg = parseIncoming(data);
|
|
150
|
+
if (msg.type === "ready") {
|
|
151
|
+
off();
|
|
152
|
+
resolve(msg.sandboxId);
|
|
153
|
+
}
|
|
154
|
+
else if (msg.type === "error") {
|
|
155
|
+
off();
|
|
156
|
+
reject(new ShellError(msg.message));
|
|
157
|
+
}
|
|
158
|
+
else if (msg.type === "exit") {
|
|
159
|
+
off();
|
|
160
|
+
reject(new ShellError("the shell closed before it was ready"));
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const onClose = () => {
|
|
164
|
+
off();
|
|
165
|
+
reject(new ShellError("the shell closed before it was ready"));
|
|
166
|
+
};
|
|
167
|
+
const off = listen(socket, onMessage, onClose);
|
|
168
|
+
});
|
|
169
|
+
const shell = new Shell(socket, sandboxId);
|
|
170
|
+
shell.start();
|
|
171
|
+
if (opts.signal) {
|
|
172
|
+
opts.signal.addEventListener("abort", () => void shell.close(), { once: true });
|
|
173
|
+
}
|
|
174
|
+
return shell;
|
|
175
|
+
}
|
|
176
|
+
start() {
|
|
177
|
+
listen(this.socket, (data) => {
|
|
178
|
+
const msg = parseIncoming(data);
|
|
179
|
+
if (msg.type === "output") {
|
|
180
|
+
for (const l of this.listeners)
|
|
181
|
+
l(msg.data);
|
|
182
|
+
}
|
|
183
|
+
else if (msg.type === "error") {
|
|
184
|
+
// Non-terminal, but latched: this is what makes a later `exit: 0`
|
|
185
|
+
// legible as a failure.
|
|
186
|
+
this.lastError = msg.message;
|
|
187
|
+
for (const l of this.errorListeners)
|
|
188
|
+
l(msg.message);
|
|
189
|
+
}
|
|
190
|
+
else if (msg.type === "exit") {
|
|
191
|
+
this.finish(msg.code);
|
|
192
|
+
}
|
|
193
|
+
}, () => this.finish(0));
|
|
194
|
+
// Browsers have no way to send a ping frame; `ws` does.
|
|
195
|
+
if (typeof this.socket.ping === "function") {
|
|
196
|
+
this.pingTimer = setInterval(() => {
|
|
197
|
+
try {
|
|
198
|
+
this.socket.ping();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// The close handler will settle the session.
|
|
202
|
+
}
|
|
203
|
+
}, PING_INTERVAL_MS);
|
|
204
|
+
// Do not hold a Node process open just to keep pinging.
|
|
205
|
+
this.pingTimer.unref?.();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
finish(code) {
|
|
209
|
+
if (this.settled)
|
|
210
|
+
return;
|
|
211
|
+
this.settled = true;
|
|
212
|
+
if (this.pingTimer)
|
|
213
|
+
clearInterval(this.pingTimer);
|
|
214
|
+
this.resolveExit({
|
|
215
|
+
code,
|
|
216
|
+
error: this.lastError,
|
|
217
|
+
clean: code === 0 && this.lastError === undefined,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Called for every chunk the PTY produces. stderr is already merged in. */
|
|
221
|
+
onData(fn) {
|
|
222
|
+
this.listeners.push(fn);
|
|
223
|
+
}
|
|
224
|
+
/** Called for a non-fatal error. The session continues. */
|
|
225
|
+
onError(fn) {
|
|
226
|
+
this.errorListeners.push(fn);
|
|
227
|
+
}
|
|
228
|
+
/** Send stdin. */
|
|
229
|
+
write(data) {
|
|
230
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
231
|
+
this.socket.send(frameStdin(bytes));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Tell the guest its terminal changed size.
|
|
235
|
+
*
|
|
236
|
+
* Worth wiring to `process.stdout.on("resize")` — app-lb has always supported
|
|
237
|
+
* this and no client used it, so resizing a terminal mid-session left the
|
|
238
|
+
* guest PTY at whatever geometry it started with.
|
|
239
|
+
*/
|
|
240
|
+
resize(cols, rows) {
|
|
241
|
+
this.socket.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
242
|
+
}
|
|
243
|
+
/** Close the session and wait for it to settle. */
|
|
244
|
+
async close() {
|
|
245
|
+
try {
|
|
246
|
+
this.socket.close();
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Already gone.
|
|
250
|
+
}
|
|
251
|
+
this.finish(0);
|
|
252
|
+
return this.exit;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Attach a message/close listener to either socket flavour. */
|
|
256
|
+
function listen(socket, onMessage, onClose) {
|
|
257
|
+
const handleMessage = (payload) => {
|
|
258
|
+
if (typeof payload === "string")
|
|
259
|
+
return onMessage(payload);
|
|
260
|
+
if (payload instanceof Uint8Array)
|
|
261
|
+
return onMessage(payload);
|
|
262
|
+
if (payload instanceof ArrayBuffer)
|
|
263
|
+
return onMessage(new Uint8Array(payload));
|
|
264
|
+
// Browser `Blob` — only reachable with binaryType left at its default,
|
|
265
|
+
// which `connect` sets to arraybuffer, so this is belt and braces.
|
|
266
|
+
if (typeof payload?.arrayBuffer === "function") {
|
|
267
|
+
void payload.arrayBuffer().then((b) => onMessage(new Uint8Array(b)));
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
if (typeof socket.on === "function") {
|
|
271
|
+
const m = (data) => handleMessage(data);
|
|
272
|
+
const c = () => onClose();
|
|
273
|
+
socket.on("message", m);
|
|
274
|
+
socket.on("close", c);
|
|
275
|
+
return () => {
|
|
276
|
+
socket.off?.("message", m);
|
|
277
|
+
socket.off?.("close", c);
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const m = (ev) => handleMessage(ev.data);
|
|
281
|
+
const c = () => onClose();
|
|
282
|
+
socket.addEventListener("message", m);
|
|
283
|
+
socket.addEventListener("close", c);
|
|
284
|
+
return () => {
|
|
285
|
+
socket.removeEventListener?.("message", m);
|
|
286
|
+
socket.removeEventListener?.("close", c);
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Open the socket, turning a pre-upgrade rejection into the same typed error the
|
|
291
|
+
* HTTP routes produce.
|
|
292
|
+
*
|
|
293
|
+
* Everything that can fail with a status does so *before* the upgrade — auth,
|
|
294
|
+
* scope, the deployment, VM availability — so callers need one error vocabulary
|
|
295
|
+
* rather than two.
|
|
296
|
+
*/
|
|
297
|
+
async function connect(url, header, id, client) {
|
|
298
|
+
let socket;
|
|
299
|
+
if (canSetWsHeaders()) {
|
|
300
|
+
// Node, Bun, Deno: `ws` can set headers, and the global cannot.
|
|
301
|
+
const mod = await import("ws").catch(() => {
|
|
302
|
+
throw new ShellError("shell sessions on Node need the `ws` package — add it as a dependency");
|
|
303
|
+
});
|
|
304
|
+
const WS = mod.default ?? mod.WebSocket;
|
|
305
|
+
socket = new WS(url, header ? { headers: { authorization: header } } : undefined);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
const WS = globalThis.WebSocket;
|
|
309
|
+
if (!WS)
|
|
310
|
+
throw new ShellError("this runtime has no WebSocket");
|
|
311
|
+
socket = new WS(url);
|
|
312
|
+
socket.binaryType = "arraybuffer";
|
|
313
|
+
}
|
|
314
|
+
await new Promise((resolve, reject) => {
|
|
315
|
+
const ok = () => resolve();
|
|
316
|
+
const bad = async (ev) => {
|
|
317
|
+
// `ws` reports the rejected handshake; a browser is told nothing beyond
|
|
318
|
+
// "it failed", by design, so all we can do is say the upgrade was refused
|
|
319
|
+
// and suggest the usual cause.
|
|
320
|
+
const status = ev?.status ?? ev?.target?._req?.res?.statusCode;
|
|
321
|
+
if (typeof status === "number") {
|
|
322
|
+
reject(fromResponse(status, "", "deployment", id, client.credential()));
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
reject(new ShellError(`the shell upgrade to ${url.split("?")[0]} was refused — check the ` +
|
|
326
|
+
"credential and that the deployment has a VM"));
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
if (typeof socket.on === "function") {
|
|
330
|
+
socket.on("open", ok);
|
|
331
|
+
socket.on("unexpected-response", (_req, res) => reject(fromResponse(res.statusCode, "", "deployment", id, client.credential())));
|
|
332
|
+
socket.on("error", bad);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
socket.addEventListener("open", ok);
|
|
336
|
+
socket.addEventListener("error", bad);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
return socket;
|
|
340
|
+
}
|