@volter/browser-sandboxes 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/control-plane.js +0 -0
- package/dist/bin/mcp.js +0 -0
- package/dist/client.d.ts +26 -27
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +270 -194
- package/dist/client.js.map +1 -1
- package/dist/contract.d.ts +11 -3
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +74 -26
- package/dist/contract.js.map +1 -1
- package/dist/control-plane-client.js +1 -1
- package/dist/control-plane.js +1 -1
- package/dist/control-plane.js.map +1 -1
- package/dist/manager.d.ts +1 -1
- package/dist/manager.js +2 -2
- package/dist/transfer.d.ts.map +1 -1
- package/dist/transfer.js +8 -7
- package/dist/transfer.js.map +1 -1
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,64 +1,132 @@
|
|
|
1
|
-
// A
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
// A client of the container's wire (ADR-0026): Docker's Engine API, the
|
|
2
|
+
// named subset, over fetch and a WebSocket, with the in-page runtime's method
|
|
3
|
+
// names so a tab drives a hosted container the way it drives its own. Where
|
|
4
|
+
// Docker's API lacks a thing the transfer needs, the client speaks the row
|
|
5
|
+
// the daemon names: a port through the daemon, an event stream, a file
|
|
6
|
+
// listing and removal, and an archive `exclude`, `replace` and `strip`.
|
|
7
|
+
// Browser-safe: no Node globals.
|
|
8
|
+
import { DOCKER_ENGINE_BASE_PATH, multiplexFrame } from "@volter/browser-runtime/docker-engine.js";
|
|
9
|
+
import { packTar, unpackTar } from "@volter/browser-runtime/browser-tar.js";
|
|
10
|
+
const KILL_CLOSE_CODE = 4001;
|
|
8
11
|
function toBody(data) {
|
|
9
|
-
return typeof data === "string" ? data : data
|
|
12
|
+
return typeof data === "string" ? data : new Uint8Array(data).buffer;
|
|
13
|
+
}
|
|
14
|
+
function dirname(path) { const index = path.lastIndexOf("/"); return index <= 0 ? "/" : path.slice(0, index); }
|
|
15
|
+
function basename(path) { return path.slice(path.lastIndexOf("/") + 1); }
|
|
16
|
+
/** Splits Docker's multiplexed stream into stdout and stderr chunks, across message boundaries. */
|
|
17
|
+
class FrameParser {
|
|
18
|
+
onFrame;
|
|
19
|
+
buffered = new Uint8Array(0);
|
|
20
|
+
constructor(onFrame) {
|
|
21
|
+
this.onFrame = onFrame;
|
|
22
|
+
}
|
|
23
|
+
push(bytes) {
|
|
24
|
+
const joined = new Uint8Array(this.buffered.byteLength + bytes.byteLength);
|
|
25
|
+
joined.set(this.buffered);
|
|
26
|
+
joined.set(bytes, this.buffered.byteLength);
|
|
27
|
+
let offset = 0;
|
|
28
|
+
while (joined.byteLength - offset >= 8) {
|
|
29
|
+
const size = new DataView(joined.buffer, joined.byteOffset + offset).getUint32(4, false);
|
|
30
|
+
if (joined.byteLength - offset < 8 + size)
|
|
31
|
+
break;
|
|
32
|
+
this.onFrame(joined[offset], joined.subarray(offset + 8, offset + 8 + size));
|
|
33
|
+
offset += 8 + size;
|
|
34
|
+
}
|
|
35
|
+
this.buffered = joined.slice(offset);
|
|
36
|
+
}
|
|
10
37
|
}
|
|
11
38
|
export class RemoteRuntime {
|
|
12
39
|
files;
|
|
13
40
|
base;
|
|
14
|
-
/** The catalogue's base path, from the negotiated descriptor; /v1 until then. */
|
|
15
|
-
basePath = SUBSTRATE_PROTOCOL_BASE_PATH;
|
|
16
|
-
/** The agent's descriptor once negotiated. */
|
|
17
|
-
descriptor;
|
|
18
41
|
token;
|
|
19
42
|
network;
|
|
43
|
+
makeSocket;
|
|
20
44
|
listeners = new Map();
|
|
21
|
-
outputs = new Map();
|
|
22
|
-
pending = new Map();
|
|
23
45
|
abort = new AbortController();
|
|
46
|
+
/** The one container this daemon holds, known after `connect`. */
|
|
47
|
+
containerId;
|
|
48
|
+
startedAt = Date.now();
|
|
24
49
|
constructor(baseUrl, token, options = {}) {
|
|
25
50
|
this.base = baseUrl.replace(/\/$/, "");
|
|
26
51
|
this.token = token;
|
|
27
52
|
this.network = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
53
|
+
this.makeSocket = options.socket ?? ((url) => new WebSocket(url));
|
|
28
54
|
this.files = {
|
|
29
|
-
read: async (p) =>
|
|
55
|
+
read: async (p) => {
|
|
56
|
+
const entries = unpackTar(new Uint8Array(await (await this.ok(await this.call(`${this.container}/archive?path=${encodeURIComponent(p)}`))).arrayBuffer()));
|
|
57
|
+
const file = entries.find((entry) => entry.type === "file");
|
|
58
|
+
if (!file)
|
|
59
|
+
throw new Error(`${p} is not a file`);
|
|
60
|
+
return file.data ?? new Uint8Array(0);
|
|
61
|
+
},
|
|
30
62
|
readText: async (p) => new TextDecoder().decode(await this.files.read(p)),
|
|
31
|
-
write: async (p, data) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
63
|
+
write: async (p, data) => {
|
|
64
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
65
|
+
const tar = packTar([{ path: basename(p), type: "file", mode: 0o644, mtime: Math.floor(Date.now() / 1000), data: bytes }]);
|
|
66
|
+
await this.files.mkdir(dirname(p));
|
|
67
|
+
await this.ok(await this.call(`${this.container}/archive?path=${encodeURIComponent(dirname(p))}`, { method: "PUT", headers: { "content-type": "application/x-tar" }, body: toBody(tar) }));
|
|
68
|
+
return { ok: true };
|
|
69
|
+
},
|
|
70
|
+
list: async (p) => (await this.json(await this.call(`${this.container}/files?path=${encodeURIComponent(p)}`))).map(({ name, type, size }) => ({ name, type, size })),
|
|
71
|
+
mkdir: async (p) => {
|
|
72
|
+
// A directory is made by extracting one into its parent, parent by parent from the root down.
|
|
73
|
+
const parts = p.split("/").filter(Boolean);
|
|
74
|
+
for (let depth = 1; depth <= parts.length; depth += 1) {
|
|
75
|
+
const parent = `/${parts.slice(0, depth - 1).join("/")}`;
|
|
76
|
+
const name = parts[depth - 1];
|
|
77
|
+
const listed = await this.files.list(parent).catch(() => []);
|
|
78
|
+
if (listed.some((entry) => entry.name === name))
|
|
79
|
+
continue;
|
|
80
|
+
const tar = packTar([{ path: name, type: "directory", mode: 0o755, mtime: Math.floor(Date.now() / 1000) }]);
|
|
81
|
+
await this.ok(await this.call(`${this.container}/archive?path=${encodeURIComponent(parent)}`, { method: "PUT", headers: { "content-type": "application/x-tar" }, body: toBody(tar) }));
|
|
82
|
+
}
|
|
83
|
+
return { ok: true };
|
|
84
|
+
},
|
|
85
|
+
remove: async (p) => { await this.ok(await this.call(`${this.container}/files?path=${encodeURIComponent(p)}`, { method: "DELETE" })); return { ok: true }; },
|
|
86
|
+
export: async (root = "/workspace", _format = "tar", options = {}) => {
|
|
37
87
|
const exclude = options.exclude?.length ? `&exclude=${encodeURIComponent(options.exclude.join(","))}` : "";
|
|
38
|
-
const response = await this.ok(await this.call(
|
|
88
|
+
const response = await this.ok(await this.call(`${this.container}/archive?path=${encodeURIComponent(root)}${exclude}`));
|
|
39
89
|
return {
|
|
40
|
-
format,
|
|
90
|
+
format: "tar",
|
|
41
91
|
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
42
92
|
integrity: response.headers.get("x-substrate-integrity"),
|
|
43
93
|
entries: Number(response.headers.get("x-substrate-entries")),
|
|
44
94
|
fileBytes: Number(response.headers.get("x-substrate-file-bytes")),
|
|
45
95
|
};
|
|
46
96
|
},
|
|
47
|
-
import: async (bytes, { root = "/workspace", replace = true } = {}) =>
|
|
97
|
+
import: async (bytes, { root = "/workspace", replace = true } = {}) => {
|
|
98
|
+
await this.files.mkdir(root);
|
|
99
|
+
// The archive's top entry is the tree's own name; `strip=1` lands its contents in `root`.
|
|
100
|
+
const summary = await this.json(await this.call(`${this.container}/archive?path=${encodeURIComponent(root)}&replace=${replace}&strip=1`, { method: "PUT", headers: { "content-type": "application/x-tar" }, body: toBody(bytes) }));
|
|
101
|
+
return { format: "tar", ...summary };
|
|
102
|
+
},
|
|
48
103
|
};
|
|
49
104
|
}
|
|
105
|
+
get container() {
|
|
106
|
+
if (!this.containerId)
|
|
107
|
+
throw new Error("connect() first: the container is not known yet.");
|
|
108
|
+
return `/containers/${this.containerId}`;
|
|
109
|
+
}
|
|
50
110
|
/**
|
|
51
|
-
*
|
|
52
|
-
* the
|
|
53
|
-
* refused with
|
|
111
|
+
* Pings the daemon, finds its one container, opens the event stream, and
|
|
112
|
+
* returns the container's health. A server that is not a Docker daemon of
|
|
113
|
+
* ours is refused with its status.
|
|
54
114
|
*/
|
|
55
115
|
async connect() {
|
|
56
|
-
await this.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
116
|
+
const ping = await this.call("/_ping");
|
|
117
|
+
if (!ping.ok)
|
|
118
|
+
throw new Error(`${this.base} did not answer Docker's ping: ${ping.status} ${await ping.text().catch(() => "")}`);
|
|
119
|
+
const containers = await this.json(await this.call("/containers/json"));
|
|
120
|
+
const first = containers[0];
|
|
121
|
+
if (!first)
|
|
122
|
+
throw new Error(`${this.base} holds no container.`);
|
|
123
|
+
this.containerId = first.Id;
|
|
124
|
+
this.startedAt = first.Created * 1000;
|
|
125
|
+
const events = await this.call(`${this.container}/events`, { signal: this.abort.signal });
|
|
126
|
+
await this.ok(events);
|
|
127
|
+
if (events.body)
|
|
128
|
+
void this.consume(events.body);
|
|
129
|
+
return this.health();
|
|
62
130
|
}
|
|
63
131
|
async consume(body) {
|
|
64
132
|
const decoder = new TextDecoder();
|
|
@@ -86,26 +154,7 @@ export class RemoteRuntime {
|
|
|
86
154
|
}
|
|
87
155
|
}
|
|
88
156
|
dispatch(event) {
|
|
89
|
-
const
|
|
90
|
-
if (type === "pty-output" || type === "pty-exit") {
|
|
91
|
-
const key = `pty:${String(event.id)}`;
|
|
92
|
-
const sink = this.outputs.get(key);
|
|
93
|
-
if (sink)
|
|
94
|
-
sink(event);
|
|
95
|
-
else
|
|
96
|
-
this.pending.set(key, [...(this.pending.get(key) ?? []), event]);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
if (type === "output") {
|
|
100
|
-
const key = Number(event.pid);
|
|
101
|
-
const sink = this.outputs.get(key);
|
|
102
|
-
if (sink)
|
|
103
|
-
sink(event);
|
|
104
|
-
else
|
|
105
|
-
this.pending.set(key, [...(this.pending.get(key) ?? []), event]);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
for (const listener of this.listeners.get(type) ?? [])
|
|
157
|
+
for (const listener of this.listeners.get(String(event.type)) ?? [])
|
|
109
158
|
listener(event);
|
|
110
159
|
}
|
|
111
160
|
on(type, listener) {
|
|
@@ -114,77 +163,158 @@ export class RemoteRuntime {
|
|
|
114
163
|
this.listeners.set(type, set);
|
|
115
164
|
return () => { set.delete(listener); };
|
|
116
165
|
}
|
|
117
|
-
async
|
|
118
|
-
|
|
119
|
-
return this.startTerminalProcess(command, options);
|
|
120
|
-
const { onStdout, onStderr, cwd, env, stdin } = options;
|
|
121
|
-
const started = await this.json(await this.call("/processes", {
|
|
166
|
+
async execCreate(cmd, options) {
|
|
167
|
+
const created = await this.json(await this.call(`${this.container}/exec`, {
|
|
122
168
|
method: "POST",
|
|
123
169
|
headers: { "content-type": "application/json" },
|
|
124
|
-
body: JSON.stringify({
|
|
170
|
+
body: JSON.stringify({
|
|
171
|
+
Cmd: cmd,
|
|
172
|
+
Tty: options.tty,
|
|
173
|
+
AttachStdin: options.stdin,
|
|
174
|
+
AttachStdout: true,
|
|
175
|
+
AttachStderr: true,
|
|
176
|
+
...(options.cwd !== undefined ? { WorkingDir: options.cwd } : {}),
|
|
177
|
+
...(options.env ? { Env: Object.entries(options.env).map(([key, value]) => `${key}=${value}`) } : {}),
|
|
178
|
+
}),
|
|
125
179
|
}));
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
return created.Id;
|
|
181
|
+
}
|
|
182
|
+
async execExitCode(id) {
|
|
183
|
+
const inspect = await this.json(await this.call(`/exec/${id}/json`));
|
|
184
|
+
return inspect.ExitCode ?? 1;
|
|
185
|
+
}
|
|
186
|
+
execSocket(id, options = {}) {
|
|
187
|
+
const url = new URL(`${this.base}${DOCKER_ENGINE_BASE_PATH}/exec/${id}/start`);
|
|
188
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
189
|
+
url.searchParams.set("token", this.tokenValue());
|
|
190
|
+
if (options.rows)
|
|
191
|
+
url.searchParams.set("h", String(options.rows));
|
|
192
|
+
if (options.columns)
|
|
193
|
+
url.searchParams.set("w", String(options.columns));
|
|
194
|
+
const socket = this.makeSocket(url.href);
|
|
195
|
+
socket.binaryType = "arraybuffer";
|
|
196
|
+
return socket;
|
|
197
|
+
}
|
|
198
|
+
async start(command, options = {}) {
|
|
199
|
+
const tty = options.terminal !== undefined;
|
|
200
|
+
const wantsStdin = options.stdin !== undefined || options.stdinStream !== undefined || tty;
|
|
201
|
+
const id = await this.execCreate(["sh", "-c", command], { tty, stdin: wantsStdin, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.env ? { env: options.env } : {}) });
|
|
202
|
+
const startedAt = Date.now();
|
|
131
203
|
let state = "running";
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
command: started.command,
|
|
142
|
-
startedAt: started.startedAt,
|
|
143
|
-
get state() { return state; },
|
|
144
|
-
result,
|
|
145
|
-
kill: () => { void this.call(`/processes/${started.pid}/kill`, { method: "POST" }); },
|
|
146
|
-
resize: () => undefined,
|
|
204
|
+
let stdout = "";
|
|
205
|
+
let stderr = "";
|
|
206
|
+
const decoderOut = new TextDecoder();
|
|
207
|
+
const decoderErr = new TextDecoder();
|
|
208
|
+
const onOut = (bytes) => { const text = decoderOut.decode(bytes, { stream: true }); stdout += text; options.onStdout?.(text); };
|
|
209
|
+
const onErr = (bytes) => { const text = decoderErr.decode(bytes, { stream: true }); stderr += text; options.onStderr?.(text); };
|
|
210
|
+
const settle = (exitCode) => {
|
|
211
|
+
state = "exited";
|
|
212
|
+
return { stdout, stderr, exitCode, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.env ? { env: options.env } : {}) };
|
|
147
213
|
};
|
|
214
|
+
let kill = () => undefined;
|
|
215
|
+
let resize = () => undefined;
|
|
216
|
+
let result;
|
|
217
|
+
if (!wantsStdin) {
|
|
218
|
+
// Output only: the hijacked HTTP form, streamed.
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
kill = () => controller.abort();
|
|
221
|
+
result = (async () => {
|
|
222
|
+
const response = await this.ok(await this.call(`/exec/${id}/start`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}", signal: controller.signal }));
|
|
223
|
+
const parser = new FrameParser((stream, payload) => (stream === 2 ? onErr : onOut)(payload));
|
|
224
|
+
const reader = response.body?.getReader();
|
|
225
|
+
if (reader)
|
|
226
|
+
for (;;) {
|
|
227
|
+
const { done, value } = await reader.read();
|
|
228
|
+
if (done)
|
|
229
|
+
break;
|
|
230
|
+
parser.push(value);
|
|
231
|
+
}
|
|
232
|
+
return settle(await this.execExitCode(id));
|
|
233
|
+
})().catch((cause) => { state = "failed"; throw cause; });
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// Input too: the socket, framed as the daemon frames it.
|
|
237
|
+
const socket = this.execSocket(id, tty ? { rows: options.terminal.rows, columns: options.terminal.columns } : {});
|
|
238
|
+
const parser = new FrameParser((stream, payload) => (stream === 2 ? onErr : onOut)(payload));
|
|
239
|
+
kill = () => socket.close(KILL_CLOSE_CODE, "kill");
|
|
240
|
+
resize = (columns, rows) => { void this.call(`/exec/${id}/resize?h=${rows}&w=${columns}`, { method: "POST" }).catch(() => undefined); };
|
|
241
|
+
const offResize = options.terminal?.onResize?.((columns, rows) => resize(columns, rows));
|
|
242
|
+
result = new Promise((resolve, reject) => {
|
|
243
|
+
socket.onmessage = (event) => {
|
|
244
|
+
const bytes = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : new TextEncoder().encode(String(event.data));
|
|
245
|
+
if (tty)
|
|
246
|
+
onOut(bytes);
|
|
247
|
+
else
|
|
248
|
+
parser.push(bytes);
|
|
249
|
+
};
|
|
250
|
+
socket.onopen = () => {
|
|
251
|
+
// End of input is an empty binary frame (the daemon's rule); the daemon closes the socket when the process exits.
|
|
252
|
+
if (options.stdin !== undefined) {
|
|
253
|
+
socket.send(options.stdin);
|
|
254
|
+
if (!tty)
|
|
255
|
+
socket.send(new ArrayBuffer(0));
|
|
256
|
+
}
|
|
257
|
+
if (options.stdinStream) {
|
|
258
|
+
void (async () => {
|
|
259
|
+
try {
|
|
260
|
+
for await (const chunk of options.stdinStream)
|
|
261
|
+
socket.send(new Uint8Array(chunk).buffer);
|
|
262
|
+
if (tty)
|
|
263
|
+
socket.send("\u0004");
|
|
264
|
+
else
|
|
265
|
+
socket.send(new ArrayBuffer(0));
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
socket.close(KILL_CLOSE_CODE, "stdin failed");
|
|
269
|
+
}
|
|
270
|
+
})();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
socket.onerror = () => { state = "failed"; reject(new Error(`the exec's socket failed: ${command}`)); };
|
|
274
|
+
socket.onclose = () => {
|
|
275
|
+
offResize?.();
|
|
276
|
+
this.execExitCode(id).then((code) => resolve(settle(code)), (cause) => { state = "failed"; reject(cause instanceof Error ? cause : new Error(String(cause))); });
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
void result.catch(() => undefined);
|
|
281
|
+
return { pid: 0, command, startedAt, get state() { return state; }, result, kill: () => kill(), resize: (columns, rows) => resize(columns, rows) };
|
|
148
282
|
}
|
|
149
283
|
async spawn(command, options = {}) {
|
|
150
284
|
return (await this.start(command, options)).result;
|
|
151
285
|
}
|
|
152
|
-
/** A terminal session
|
|
286
|
+
/** A terminal session: an exec of the command with a TTY over the socket; bytes in, bytes out. */
|
|
153
287
|
async openPty(options = {}) {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
288
|
+
const { command = "sh", cols = 80, rows = 24, cwd, env } = options;
|
|
289
|
+
const id = await this.execCreate(["sh", "-c", command], { tty: true, stdin: true, ...(cwd !== undefined ? { cwd } : {}), ...(env ? { env } : {}) });
|
|
290
|
+
const socket = this.execSocket(id, { rows, columns: cols });
|
|
156
291
|
const listeners = new Set();
|
|
157
292
|
const retained = [];
|
|
158
293
|
let retainedBytes = 0;
|
|
159
|
-
|
|
160
|
-
const exited = new Promise((resolve) => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
retainedBytes -= retained.shift().byteLength;
|
|
169
|
-
}
|
|
170
|
-
else {
|
|
171
|
-
for (const listener of listeners)
|
|
172
|
-
listener(bytes);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
this.outputs.delete(key);
|
|
177
|
-
resolveExit(Number(event.exitCode));
|
|
294
|
+
const opened = new Promise((resolve, reject) => { socket.onopen = () => resolve(); socket.onerror = () => reject(new Error("the terminal's socket failed")); });
|
|
295
|
+
const exited = new Promise((resolve) => { socket.onclose = () => { void this.execExitCode(id).then(resolve, () => resolve(1)); }; });
|
|
296
|
+
socket.onmessage = (event) => {
|
|
297
|
+
const bytes = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : new TextEncoder().encode(String(event.data));
|
|
298
|
+
if (listeners.size === 0) {
|
|
299
|
+
retained.push(bytes);
|
|
300
|
+
retainedBytes += bytes.byteLength;
|
|
301
|
+
while (retainedBytes > 64 * 1_024 && retained.length > 1)
|
|
302
|
+
retainedBytes -= retained.shift().byteLength;
|
|
178
303
|
}
|
|
304
|
+
else
|
|
305
|
+
for (const listener of listeners)
|
|
306
|
+
listener(bytes);
|
|
179
307
|
};
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
sink(event);
|
|
183
|
-
this.pending.delete(key);
|
|
308
|
+
await opened;
|
|
309
|
+
let size = { cols, rows };
|
|
184
310
|
return {
|
|
185
|
-
id
|
|
186
|
-
write: (data) =>
|
|
187
|
-
resize: (
|
|
311
|
+
id, pid: 0, cols, rows, allocator: "exec", resizable: true,
|
|
312
|
+
write: async (data) => { socket.send(typeof data === "string" ? data : new Uint8Array(data).buffer); },
|
|
313
|
+
resize: async (nextCols, nextRows) => {
|
|
314
|
+
await this.ok(await this.call(`/exec/${id}/resize?h=${nextRows}&w=${nextCols}`, { method: "POST" }));
|
|
315
|
+
size = { cols: nextCols, rows: nextRows };
|
|
316
|
+
return size;
|
|
317
|
+
},
|
|
188
318
|
onData: (listener) => {
|
|
189
319
|
listeners.add(listener);
|
|
190
320
|
for (const bytes of retained.splice(0))
|
|
@@ -192,109 +322,53 @@ export class RemoteRuntime {
|
|
|
192
322
|
retainedBytes = 0;
|
|
193
323
|
return () => { listeners.delete(listener); };
|
|
194
324
|
},
|
|
195
|
-
kill: () =>
|
|
325
|
+
kill: () => socket.close(KILL_CLOSE_CODE, "kill"),
|
|
196
326
|
exited,
|
|
197
327
|
};
|
|
198
328
|
}
|
|
199
|
-
/**
|
|
200
|
-
async
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const pty = await this.openPty({
|
|
204
|
-
command,
|
|
205
|
-
cols: terminal.columns,
|
|
206
|
-
rows: terminal.rows,
|
|
207
|
-
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
208
|
-
...(options.env !== undefined ? { env: options.env } : {}),
|
|
209
|
-
});
|
|
210
|
-
const decoder = new TextDecoder();
|
|
211
|
-
let stdout = "";
|
|
212
|
-
let state = "running";
|
|
213
|
-
const offData = pty.onData((bytes) => {
|
|
214
|
-
const chunk = decoder.decode(bytes, { stream: true });
|
|
215
|
-
stdout += chunk;
|
|
216
|
-
options.onStdout?.(chunk);
|
|
217
|
-
});
|
|
218
|
-
const offResize = terminal.onResize?.((columns, rows) => {
|
|
219
|
-
void pty.resize(columns, rows).catch(() => undefined);
|
|
220
|
-
});
|
|
221
|
-
if (options.stdin !== undefined)
|
|
222
|
-
await pty.write(options.stdin);
|
|
223
|
-
if (options.stdinStream) {
|
|
224
|
-
void (async () => {
|
|
225
|
-
try {
|
|
226
|
-
for await (const chunk of options.stdinStream)
|
|
227
|
-
await pty.write(chunk);
|
|
228
|
-
await pty.write("\u0004");
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
pty.kill();
|
|
232
|
-
}
|
|
233
|
-
})();
|
|
234
|
-
}
|
|
235
|
-
const result = pty.exited.then((exitCode) => {
|
|
236
|
-
const tail = decoder.decode();
|
|
237
|
-
if (tail) {
|
|
238
|
-
stdout += tail;
|
|
239
|
-
options.onStdout?.(tail);
|
|
240
|
-
}
|
|
241
|
-
offData();
|
|
242
|
-
offResize?.();
|
|
243
|
-
state = "exited";
|
|
244
|
-
return { stdout, stderr: "", exitCode, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.env !== undefined ? { env: options.env } : {}) };
|
|
245
|
-
}, (cause) => {
|
|
246
|
-
offData();
|
|
247
|
-
offResize?.();
|
|
248
|
-
state = "failed";
|
|
249
|
-
throw cause;
|
|
250
|
-
});
|
|
251
|
-
void result.catch(() => undefined);
|
|
329
|
+
/** The container's health, from inspect and top; never counts as activity on the daemon's clock beyond the request. */
|
|
330
|
+
async health() {
|
|
331
|
+
const inspect = await this.json(await this.call(`${this.container}/json`));
|
|
332
|
+
const top = await this.json(await this.call(`${this.container}/top`));
|
|
252
333
|
return {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
334
|
+
ok: true,
|
|
335
|
+
root: inspect.Config.WorkingDir,
|
|
336
|
+
portDiscovery: "inspect",
|
|
337
|
+
ports: Object.keys(inspect.NetworkSettings.Ports).map((key) => Number(key.split("/")[0])),
|
|
338
|
+
startedAt: this.startedAt,
|
|
339
|
+
lastActivityAt: Date.now(),
|
|
340
|
+
liveProcesses: top.Processes.filter((row) => row[2] === "running").length,
|
|
260
341
|
};
|
|
261
342
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const descriptor = response.ok ? await response.json().catch(() => undefined) : undefined;
|
|
266
|
-
const negotiated = negotiateHostedProtocol(descriptor, { protocol: SUBSTRATE_AGENT_PROTOCOL });
|
|
267
|
-
if (!negotiated.ok)
|
|
268
|
-
throw new Error(`${this.base} is not a ${SUBSTRATE_AGENT_PROTOCOL} server: ${negotiated.reason}`);
|
|
269
|
-
this.descriptor = negotiated.descriptor;
|
|
270
|
-
this.basePath = negotiated.descriptor.basePath.replace(/\/$/, "");
|
|
271
|
-
return negotiated.descriptor;
|
|
343
|
+
async processes() {
|
|
344
|
+
const top = await this.json(await this.call(`${this.container}/top`));
|
|
345
|
+
return top.Processes.map((row) => ({ pid: Number(row[0]), startedAt: Date.parse(row[1] ?? "") || 0, state: row[2] ?? "", command: row[3] ?? "" }));
|
|
272
346
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
async processes() { return this.json(await this.call("/processes")); }
|
|
276
|
-
async ports() { return (await this.json(await this.call("/ports"))).map((entry) => entry.port); }
|
|
277
|
-
portUrl(port) { return `${this.base}${this.basePath}/ports/${port}/`; }
|
|
347
|
+
async ports() { return (await this.health()).ports; }
|
|
348
|
+
portUrl(port) { return `${this.base}${DOCKER_ENGINE_BASE_PATH}${this.container}/ports/${port}/`; }
|
|
278
349
|
request(port, input = "/", init = {}) {
|
|
279
|
-
// An absolute virtual path is relative to the port, never to the
|
|
280
|
-
// the
|
|
281
|
-
const target = new URL(input.toString().replace(/^\//, ""),
|
|
282
|
-
return this.call(target.href.slice(this.base.length +
|
|
350
|
+
// An absolute virtual path is relative to the port, never to the daemon,
|
|
351
|
+
// and the daemon may itself sit behind a path prefix such as a control plane.
|
|
352
|
+
const target = new URL(input.toString().replace(/^\//, ""), this.portUrl(port));
|
|
353
|
+
return this.call(target.href.slice(this.base.length + DOCKER_ENGINE_BASE_PATH.length), init);
|
|
354
|
+
}
|
|
355
|
+
/** The profiler's rows, carried under `substrate` in Docker's stats. */
|
|
356
|
+
async metrics() {
|
|
357
|
+
const stats = await this.json(await this.call(`${this.container}/stats?stream=false`));
|
|
358
|
+
return stats.substrate ?? {};
|
|
283
359
|
}
|
|
284
|
-
|
|
285
|
-
/** A WebSocket to a guest port, tunnelled through the agent. */
|
|
360
|
+
/** A WebSocket to a guest port, tunnelled through the daemon. */
|
|
286
361
|
webSocket(port, input = "/") {
|
|
287
|
-
const target = new URL(input.toString().replace(/^\//, ""),
|
|
362
|
+
const target = new URL(input.toString().replace(/^\//, ""), this.portUrl(port));
|
|
288
363
|
target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
|
|
289
364
|
target.searchParams.set("token", this.tokenValue());
|
|
290
|
-
return
|
|
365
|
+
return this.makeSocket(target.href);
|
|
291
366
|
}
|
|
292
367
|
close() { this.abort.abort(); }
|
|
293
368
|
call(route, init = {}) {
|
|
294
369
|
const headers = new Headers(init.headers);
|
|
295
370
|
headers.set("authorization", `Bearer ${this.tokenValue()}`);
|
|
296
|
-
|
|
297
|
-
return this.network(`${this.base}${this.basePath}${route}`, { ...init, headers });
|
|
371
|
+
return this.network(`${this.base}${DOCKER_ENGINE_BASE_PATH}${route}`, { ...init, headers });
|
|
298
372
|
}
|
|
299
373
|
tokenValue() { return typeof this.token === "function" ? (this.token() ?? "") : this.token; }
|
|
300
374
|
async ok(response) {
|
|
@@ -306,4 +380,6 @@ export class RemoteRuntime {
|
|
|
306
380
|
return (await this.ok(await responseOrPromise)).json();
|
|
307
381
|
}
|
|
308
382
|
}
|
|
383
|
+
/** Frames a chunk the way the daemon frames stdout, for tests and tools that need the shape. */
|
|
384
|
+
export const dockerFrame = multiplexFrame;
|
|
309
385
|
//# sourceMappingURL=client.js.map
|