tassello 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +175 -8
  3. package/dist/server/behavior_tree_files.d.ts +0 -1
  4. package/dist/server/behavior_tree_files.js +2 -25
  5. package/dist/server/diagnostics.d.ts +16 -0
  6. package/dist/server/diagnostics.js +200 -0
  7. package/dist/server/diagnostics_state.d.ts +23 -0
  8. package/dist/server/diagnostics_state.js +146 -0
  9. package/dist/server/disks.d.ts +14 -0
  10. package/dist/server/disks.js +152 -0
  11. package/dist/server/file_manager.d.ts +25 -0
  12. package/dist/server/file_manager.js +717 -0
  13. package/dist/server/http.js +248 -5
  14. package/dist/server/netpbm.d.ts +3 -0
  15. package/dist/server/netpbm.js +69 -0
  16. package/dist/server/networking.d.ts +41 -0
  17. package/dist/server/networking.js +508 -0
  18. package/dist/server/networking_format.d.ts +38 -0
  19. package/dist/server/networking_format.js +651 -0
  20. package/dist/server/pty.d.ts +26 -0
  21. package/dist/server/pty.js +150 -0
  22. package/dist/server/server.js +13 -2
  23. package/dist/server/session.d.ts +10 -1
  24. package/dist/server/session.js +88 -1
  25. package/dist/server/terminal_output.d.ts +25 -0
  26. package/dist/server/terminal_output.js +108 -0
  27. package/dist/server/terminals.d.ts +32 -0
  28. package/dist/server/terminals.js +366 -0
  29. package/dist/server/util.d.ts +4 -0
  30. package/dist/server/util.js +45 -1
  31. package/dist/shared/file_manager.d.ts +130 -0
  32. package/dist/shared/file_manager.js +7 -0
  33. package/dist/shared/networking.d.ts +178 -0
  34. package/dist/shared/networking.js +248 -0
  35. package/dist/shared/protocol.d.ts +103 -2
  36. package/dist/shared/protocol.js +8 -0
  37. package/dist/types/client/api.d.ts +22 -0
  38. package/dist/types/client/connection.d.ts +30 -1
  39. package/dist/types/client/icons.d.ts +33 -0
  40. package/dist/types/client/sdk/index.d.ts +5 -3
  41. package/dist/types/shared/file_manager.d.ts +130 -0
  42. package/dist/types/shared/networking.d.ts +178 -0
  43. package/dist/types/shared/protocol.d.ts +103 -2
  44. package/dist/www/assets/Joystick.js +1 -1
  45. package/dist/www/assets/RobotModelView.js +2 -2
  46. package/dist/www/assets/TerminalView.css +1 -0
  47. package/dist/www/assets/TerminalView.js +17 -0
  48. package/dist/www/assets/dialogs.js +1 -0
  49. package/dist/www/assets/icons.js +1 -1
  50. package/dist/www/assets/index.css +1 -1
  51. package/dist/www/assets/index.js +59 -19
  52. package/dist/www/assets/keys.js +3 -0
  53. package/dist/www/assets/shared/tassello-sdk.js +1 -1
  54. package/dist/www/index.html +2 -0
  55. package/docs/plugins.md +22 -0
  56. package/package.json +6 -2
@@ -0,0 +1,150 @@
1
+ // Pseudo-terminals for the terminal widgets, without a native module: Node
2
+ // cannot open one, the Python 3 that ROS 2 ships with can. A small bridge
3
+ // (BRIDGE) opens it, starts the program on it as the session leader, and
4
+ // relays its standard input to the terminal and the terminal's output to its
5
+ // standard output; lines on fd 3 resize it. When the bridge goes, the
6
+ // terminal hangs up, as when a terminal window is closed.
7
+ import { spawn } from 'node:child_process';
8
+ import { EventEmitter } from 'node:events';
9
+ // argv: columns, rows, then the program and its arguments. Exits with the
10
+ // program's exit code, 128 + the signal that killed it.
11
+ const BRIDGE = `
12
+ import fcntl, os, select, signal, struct, sys, termios
13
+
14
+ CONTROL = 3
15
+
16
+ def size(fd, cols, rows):
17
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
18
+
19
+ def send(data):
20
+ while data:
21
+ data = data[os.write(1, data):]
22
+
23
+ cols, rows, argv = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3:]
24
+ master, slave = os.openpty()
25
+ size(slave, cols, rows)
26
+ attrs = termios.tcgetattr(slave)
27
+ attrs[0] |= getattr(termios, 'IUTF8', 0o40000)
28
+ termios.tcsetattr(slave, termios.TCSANOW, attrs)
29
+ pid = os.fork()
30
+ if pid == 0:
31
+ try:
32
+ os.setsid()
33
+ fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
34
+ for fd in (0, 1, 2):
35
+ os.dup2(slave, fd)
36
+ for fd in (master, slave, CONTROL):
37
+ os.close(fd)
38
+ # Python ignores them, and what it ignores stays ignored after exec
39
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
40
+ signal.signal(signal.SIGXFSZ, signal.SIG_DFL)
41
+ os.execvp(argv[0], argv)
42
+ except BaseException as e:
43
+ os.write(2, ('tassello: cannot run %s: %s\\n' % (argv[0], e)).encode())
44
+ os._exit(127)
45
+
46
+ os.close(slave)
47
+ os.set_blocking(master, False)
48
+ inputs = [master, 0, CONTROL]
49
+ pending = b''
50
+ control = b''
51
+ status = None
52
+ while status is None:
53
+ readable, writable, _ = select.select(inputs, [master] if pending else [], [], 0.5)
54
+ if master in readable:
55
+ try:
56
+ data = os.read(master, 65536)
57
+ except BlockingIOError:
58
+ data = None
59
+ except OSError:
60
+ data = b''
61
+ if data == b'':
62
+ break
63
+ if data:
64
+ send(data)
65
+ if writable:
66
+ try:
67
+ pending = pending[os.write(master, pending):]
68
+ except BlockingIOError:
69
+ pass
70
+ if 0 in readable:
71
+ data = os.read(0, 65536)
72
+ if not data:
73
+ sys.exit(0)
74
+ pending += data
75
+ if CONTROL in readable:
76
+ data = os.read(CONTROL, 4096)
77
+ if not data:
78
+ inputs.remove(CONTROL)
79
+ control += data
80
+ while b'\\n' in control:
81
+ line, control = control.split(b'\\n', 1)
82
+ size(master, *map(int, line.split()))
83
+ # Gone, while what it left in the background keeps the terminal open:
84
+ # the rest of its output, then done
85
+ done, result = os.waitpid(pid, os.WNOHANG)
86
+ if done:
87
+ status = result
88
+ try:
89
+ data = os.read(master, 65536)
90
+ while data:
91
+ send(data)
92
+ data = os.read(master, 65536)
93
+ except OSError:
94
+ pass
95
+
96
+ if status is None:
97
+ status = os.waitpid(pid, 0)[1]
98
+ sys.exit(os.WEXITSTATUS(status) if os.WIFEXITED(status) else 128 + os.WTERMSIG(status))
99
+ `;
100
+ export class Pty extends EventEmitter {
101
+ child;
102
+ exited = false;
103
+ constructor(options) {
104
+ super();
105
+ // -I: none of the robot's PYTHONPATH and the like, which the program
106
+ // still gets
107
+ this.child = spawn('python3', ['-I', '-c', BRIDGE, String(options.cols), String(options.rows), options.file, ...options.args], { cwd: options.cwd, env: options.env, stdio: ['pipe', 'pipe', 'inherit', 'pipe'] });
108
+ this.child.stdout?.setEncoding('utf8').on('data', (chunk) => this.emit('data', chunk));
109
+ // Writes after the program is gone
110
+ this.child.stdin?.on('error', () => { });
111
+ this.child.stdio[3]?.on('error', () => { });
112
+ this.child.on('error', (e) => {
113
+ this.exited = true;
114
+ this.emit('error', e);
115
+ });
116
+ // 'close': after the last of its output
117
+ this.child.on('close', (code) => {
118
+ if (!this.exited) {
119
+ this.exited = true;
120
+ this.emit('exit', code);
121
+ }
122
+ });
123
+ }
124
+ get pid() {
125
+ return this.child.pid;
126
+ }
127
+ write(data) {
128
+ if (!this.exited) {
129
+ this.child.stdin?.write(data);
130
+ }
131
+ }
132
+ resize(cols, rows) {
133
+ if (!this.exited) {
134
+ this.child.stdio[3]?.write(`${cols} ${rows}\n`);
135
+ }
136
+ }
137
+ // Backpressure: the program blocks on its output while paused
138
+ pause() {
139
+ this.child.stdout?.pause();
140
+ }
141
+ resume() {
142
+ this.child.stdout?.resume();
143
+ }
144
+ // As a terminal window closed: the program gets SIGHUP
145
+ hangUp() {
146
+ if (!this.exited) {
147
+ this.child.kill('SIGHUP');
148
+ }
149
+ }
150
+ }
@@ -15,7 +15,9 @@ import { Arm } from './arm.js';
15
15
  import { BehaviorTreeFiles } from './behavior_tree_files.js';
16
16
  import { BehaviorTrees } from './behavior_trees.js';
17
17
  import { CommandRunner } from './commands.js';
18
+ import { Diagnostics } from './diagnostics.js';
18
19
  import { createRequestHandler } from './http.js';
20
+ import { Networking } from './networking.js';
19
21
  import { PanelStore } from './panels.js';
20
22
  import { Paths } from './paths.js';
21
23
  import { findPlugins, PluginHost, readPlugin } from './plugins.js';
@@ -26,9 +28,10 @@ import { Session } from './session.js';
26
28
  import { FrameStreams } from './streams.js';
27
29
  import { SystemdServices } from './systemd.js';
28
30
  import { Teleop } from './teleop.js';
31
+ import { Terminals } from './terminals.js';
29
32
  import { Tf } from './tf.js';
30
33
  import { Topics } from './topics.js';
31
- import { defaultPanelsDirectory, errorMessage, tasselloRoot } from './util.js';
34
+ import { defaultPanelsDirectory, errorMessage, ownPage, tasselloRoot } from './util.js';
32
35
  // Connections that miss a ping for this long are dropped: a page whose
33
36
  // network vanished never closes its socket by itself
34
37
  const HEARTBEAT_MS = 10_000;
@@ -88,6 +91,9 @@ export async function startServer(options) {
88
91
  const arm = new Arm(node, topics, logger);
89
92
  const ros = new Ros(node);
90
93
  const rosout = new Rosout(node, logger);
94
+ const diagnostics = new Diagnostics(node, logger);
95
+ const terminals = new Terminals(logger);
96
+ const networking = new Networking(logger);
91
97
  // Next to the panels
92
98
  const uploadsDir = path.join(path.dirname(panelsDir), 'images');
93
99
  const behaviorTrees = new BehaviorTrees(node, logger, uploadsDir);
@@ -133,11 +139,14 @@ export async function startServer(options) {
133
139
  arm,
134
140
  ros,
135
141
  rosout,
142
+ diagnostics,
143
+ terminals,
144
+ networking,
136
145
  behaviorTrees,
137
146
  behaviorTreeFiles,
138
147
  plugins: host,
139
148
  hostname: os.hostname(),
140
- });
149
+ }, ownPage(req.headers), req.socket.localAddress ?? null);
141
150
  sessions.add(session);
142
151
  logger.debug(`Page connected from ${req.socket.remoteAddress}`);
143
152
  let alive = true;
@@ -169,6 +178,8 @@ export async function startServer(options) {
169
178
  stopping = true;
170
179
  commands.stopAll();
171
180
  systemd.stopAll();
181
+ terminals.stopAll();
182
+ networking.stopAll();
172
183
  // Stops the robot if a page was driving it, while the node still runs
173
184
  for (const session of sessions) {
174
185
  session.close();
@@ -5,7 +5,9 @@ import type { Arm } from './arm.ts';
5
5
  import type { BehaviorTreeFiles } from './behavior_tree_files.ts';
6
6
  import type { BehaviorTrees } from './behavior_trees.ts';
7
7
  import type { CommandRunner } from './commands.ts';
8
+ import type { Diagnostics } from './diagnostics.ts';
8
9
  import type { EncodedFrame, FrameSink, StreamState } from './frame_stream.ts';
10
+ import type { Networking } from './networking.ts';
9
11
  import type { Poses } from './poses.ts';
10
12
  import type { Paths } from './paths.ts';
11
13
  import type { PluginHost } from './plugins.ts';
@@ -14,6 +16,7 @@ import type { Rosout } from './rosout.ts';
14
16
  import type { FrameStreams } from './streams.ts';
15
17
  import type { SystemdServices } from './systemd.ts';
16
18
  import type { Teleop } from './teleop.ts';
19
+ import type { Terminals } from './terminals.ts';
17
20
  import type { Tf } from './tf.ts';
18
21
  import type { Topics } from './topics.ts';
19
22
  export interface SessionServices {
@@ -28,6 +31,9 @@ export interface SessionServices {
28
31
  arm: Arm;
29
32
  ros: Ros;
30
33
  rosout: Rosout;
34
+ diagnostics: Diagnostics;
35
+ terminals: Terminals;
36
+ networking: Networking;
31
37
  behaviorTrees: BehaviorTrees;
32
38
  behaviorTreeFiles: BehaviorTreeFiles;
33
39
  plugins: PluginHost;
@@ -41,8 +47,11 @@ export declare class Session implements FrameSink, PluginSession {
41
47
  private readonly consumers;
42
48
  private readonly arms;
43
49
  private readonly playbacks;
50
+ private readonly terminals;
51
+ private readonly ownPage;
52
+ private readonly localAddress;
44
53
  private lastError;
45
- constructor(ws: WebSocket, services: SessionServices);
54
+ constructor(ws: WebSocket, services: SessionServices, ownPage: boolean, localAddress: string | null);
46
55
  send(message: ServerMessage): void;
47
56
  sendStreamStatus(id: number, state: StreamState, message: string): void;
48
57
  sendFrame(id: number, frame: EncodedFrame): void;
@@ -4,6 +4,7 @@
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { WebSocket } from 'ws';
6
6
  import { FRAME_HEADER_BYTES, } from '../shared/protocol.js';
7
+ import { checkRequest, deviceOfAddress } from './networking_format.js';
7
8
  import { errorMessage, toJson } from './util.js';
8
9
  // The same error is reported to the page at most this often: a teleop widget
9
10
  // publishing on an invalid topic would otherwise report it 10 times a second
@@ -37,6 +38,8 @@ function strings(value, field) {
37
38
  }
38
39
  return value.map((v) => v.trim()).filter(Boolean);
39
40
  }
41
+ // A terminal's name, chosen by the page
42
+ const TERMINAL_NAME = /^[\w-]{1,64}$/;
40
43
  function subscriptionId(value) {
41
44
  if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
42
45
  throw new Error('Invalid subscription id');
@@ -55,10 +58,19 @@ export class Session {
55
58
  arms = new Map();
56
59
  // The behavior tree playbacks, for their controls
57
60
  playbacks = new Map();
61
+ // The terminals, for what is typed in them
62
+ terminals = new Map();
63
+ // The page is one of the dashboard's own (see ownPage): only those open
64
+ // terminals and change the networks
65
+ ownPage;
66
+ // The server's address the page reached: the interface it comes through
67
+ localAddress;
58
68
  lastError = { message: '', at: 0 };
59
- constructor(ws, services) {
69
+ constructor(ws, services, ownPage, localAddress) {
60
70
  this.ws = ws;
61
71
  this.services = services;
72
+ this.ownPage = ownPage;
73
+ this.localAddress = localAddress;
62
74
  ws.on('message', (data, isBinary) => {
63
75
  if (!isBinary) {
64
76
  this.onMessage(data.toString());
@@ -98,6 +110,7 @@ export class Session {
98
110
  this.consumers.clear();
99
111
  this.arms.clear();
100
112
  this.playbacks.clear();
113
+ this.terminals.clear();
101
114
  }
102
115
  reportError(message) {
103
116
  const now = Date.now();
@@ -237,6 +250,79 @@ export class Session {
237
250
  }));
238
251
  return;
239
252
  }
253
+ case 'diagnostics.subscribe': {
254
+ const id = subscriptionId(message.id);
255
+ this.unsubscribe(id);
256
+ this.subscriptions.set(id, services.diagnostics.watch(string(message.topic, 'topic'), {
257
+ onStatus: (state, text) => this.sendStreamStatus(id, state, text),
258
+ onUpdate: (update) => this.send({ type: 'diagnostics', id, ...update }),
259
+ }));
260
+ return;
261
+ }
262
+ case 'terminal.open': {
263
+ const id = subscriptionId(message.id);
264
+ this.unsubscribe(id);
265
+ if (!this.ownPage) {
266
+ // e.g. another site's page, which a browser lets reach the robot
267
+ this.sendStreamStatus(id, 'error', 'Terminals open in the dashboard’s own pages only');
268
+ return;
269
+ }
270
+ if (typeof message.terminal !== 'string' || !TERMINAL_NAME.test(message.terminal)) {
271
+ throw new Error('Invalid terminal name');
272
+ }
273
+ const point = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
274
+ const view = services.terminals.open({
275
+ terminal: message.terminal,
276
+ command: optionalString(message.command),
277
+ folder: optionalString(message.folder) || '~',
278
+ cols: number(message.cols, 2, 1000, 80),
279
+ rows: number(message.rows, 1, 500, 24),
280
+ started: point(message.started),
281
+ offset: point(message.offset),
282
+ }, {
283
+ onOutput: (output) => this.send({ type: 'terminal.output', id, ...output }),
284
+ onExit: (exit) => this.send({ type: 'terminal.exit', id, ...exit }),
285
+ onStatus: (state, text) => this.sendStreamStatus(id, state, text),
286
+ });
287
+ this.terminals.set(id, view);
288
+ this.subscriptions.set(id, () => view.close());
289
+ return;
290
+ }
291
+ case 'terminal.input':
292
+ if (typeof message.data === 'string') {
293
+ this.terminals.get(subscriptionId(message.id))?.input(message.data);
294
+ }
295
+ return;
296
+ case 'terminal.resize':
297
+ this.terminals
298
+ .get(subscriptionId(message.id))
299
+ ?.resize(number(message.cols, 2, 1000, 80), number(message.rows, 1, 500, 24));
300
+ return;
301
+ case 'terminal.ack':
302
+ if (typeof message.started === 'number' && typeof message.offset === 'number') {
303
+ this.terminals.get(subscriptionId(message.id))?.ack(message.started, message.offset);
304
+ }
305
+ return;
306
+ case 'terminal.restart':
307
+ this.terminals.get(subscriptionId(message.id))?.restart();
308
+ return;
309
+ case 'network.subscribe': {
310
+ const id = subscriptionId(message.id);
311
+ this.unsubscribe(id);
312
+ this.subscriptions.set(id, services.networking.watch((state) => this.send({
313
+ type: 'network',
314
+ id,
315
+ ...state,
316
+ via: deviceOfAddress(state.devices, this.localAddress),
317
+ canChange: this.ownPage,
318
+ })));
319
+ return;
320
+ }
321
+ case 'network.action':
322
+ this.answer(message.id, this.ownPage
323
+ ? Promise.resolve(message.request).then((request) => services.networking.act(checkRequest(request)))
324
+ : Promise.reject(new Error('The networks are changed from the dashboard’s own pages only')));
325
+ return;
240
326
  case 'arm.subscribe': {
241
327
  const id = subscriptionId(message.id);
242
328
  this.unsubscribe(id);
@@ -375,5 +461,6 @@ export class Session {
375
461
  this.consumers.delete(id);
376
462
  this.arms.delete(id);
377
463
  this.playbacks.delete(id);
464
+ this.terminals.delete(id);
378
465
  }
379
466
  }
@@ -0,0 +1,25 @@
1
+ export declare class OutputLog {
2
+ private readonly limit;
3
+ private readonly chunks;
4
+ private first;
5
+ private length;
6
+ constructor(limit: number);
7
+ get start(): number;
8
+ get end(): number;
9
+ append(text: string): void;
10
+ since(offset: number): string | null;
11
+ }
12
+ export type NextOutput = {
13
+ data: string;
14
+ offset: number;
15
+ } | 'snapshot' | null;
16
+ export declare class Delivery {
17
+ offset: number;
18
+ private readonly sent;
19
+ private size;
20
+ constructor(offset: number);
21
+ get pending(): number;
22
+ next(log: OutputLog, window: number): NextOutput;
23
+ record(offset: number, size: number): void;
24
+ ack(offset: number): void;
25
+ }
@@ -0,0 +1,108 @@
1
+ // A terminal's output as its pages get it (see terminals.ts): numbered by
2
+ // offset, in characters (UTF-16 code units) since its program started, the
3
+ // latest kept for the pages that missed some; and for each page, what it was
4
+ // sent and has not written yet.
5
+ // Small chunks (echoes of what is typed) are joined into the last one, up to
6
+ // this many characters
7
+ const JOIN_BELOW = 16 * 1024;
8
+ export class OutputLog {
9
+ // At least `limit` characters, the latest, in whole chunks
10
+ limit;
11
+ chunks = [];
12
+ // Of the first chunk
13
+ first = 0;
14
+ length = 0;
15
+ constructor(limit) {
16
+ this.limit = limit;
17
+ }
18
+ // The oldest output kept
19
+ get start() {
20
+ return this.first;
21
+ }
22
+ get end() {
23
+ return this.first + this.length;
24
+ }
25
+ append(text) {
26
+ if (!text) {
27
+ return;
28
+ }
29
+ const last = this.chunks.length - 1;
30
+ if (last >= 0 && this.chunks[last].length < JOIN_BELOW) {
31
+ this.chunks[last] += text;
32
+ }
33
+ else {
34
+ this.chunks.push(text);
35
+ }
36
+ this.length += text.length;
37
+ while (this.chunks.length > 1 && this.length - this.chunks[0].length >= this.limit) {
38
+ const dropped = this.chunks.shift();
39
+ this.first += dropped.length;
40
+ this.length -= dropped.length;
41
+ }
42
+ }
43
+ // The output from offset on; null if it is no longer kept, or not yet
44
+ since(offset) {
45
+ if (offset < this.first || offset > this.end) {
46
+ return null;
47
+ }
48
+ let skip = offset - this.first;
49
+ let index = 0;
50
+ while (index < this.chunks.length && skip >= this.chunks[index].length) {
51
+ skip -= this.chunks[index].length;
52
+ index++;
53
+ }
54
+ return index === this.chunks.length ? '' : this.chunks[index].slice(skip) + this.chunks.slice(index + 1).join('');
55
+ }
56
+ }
57
+ function isHighSurrogate(code) {
58
+ return code >= 0xd800 && code <= 0xdbff;
59
+ }
60
+ // What a page was sent of the output, and has not written yet: at most a
61
+ // window of characters, so that a slow page (or one hidden, which writes
62
+ // nothing) does not pile output up on the way, and gets the screen instead
63
+ // once it lacks too much
64
+ export class Delivery {
65
+ // Where the page's output goes once it wrote what it was sent
66
+ offset;
67
+ // What it was sent: the offset after each message, and its size
68
+ sent = [];
69
+ size = 0;
70
+ constructor(offset) {
71
+ this.offset = offset;
72
+ }
73
+ // Characters sent and not written yet
74
+ get pending() {
75
+ return this.size;
76
+ }
77
+ // What the page should get next: the output it lacks, as much as the window
78
+ // leaves; the screen instead if it lacks more than a window of it, or what
79
+ // it lacks is no longer kept; null if up to date, or its window is full
80
+ next(log, window) {
81
+ const room = window - this.size;
82
+ if (this.offset === log.end || room <= 0) {
83
+ return null;
84
+ }
85
+ const data = log.end - this.offset > window ? null : log.since(this.offset);
86
+ if (data === null) {
87
+ return 'snapshot';
88
+ }
89
+ let part = data.length > room ? data.slice(0, room) : data;
90
+ if (part.length < data.length && isHighSurrogate(part.charCodeAt(part.length - 1))) {
91
+ part = part.slice(0, -1);
92
+ }
93
+ return part ? { data: part, offset: this.offset + part.length } : null;
94
+ }
95
+ // Sent: the output up to offset, in a message of size characters
96
+ record(offset, size) {
97
+ this.offset = offset;
98
+ this.sent.push({ offset, size });
99
+ this.size += size;
100
+ }
101
+ // The page wrote the output up to offset: its messages are acknowledged in
102
+ // the order they were sent, whose offsets never go back
103
+ ack(offset) {
104
+ while (this.sent.length > 0 && this.sent[0].offset <= offset) {
105
+ this.size -= this.sent.shift().size;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,32 @@
1
+ import { type TerminalExit, type TerminalOutput } from '../shared/protocol.ts';
2
+ import type { StreamState } from './frame_stream.ts';
3
+ import { type Logger } from './util.ts';
4
+ export interface TerminalRequest {
5
+ terminal: string;
6
+ command: string;
7
+ folder: string;
8
+ cols: number;
9
+ rows: number;
10
+ started: number | null;
11
+ offset: number | null;
12
+ }
13
+ export interface TerminalWatcher {
14
+ onOutput: (output: Omit<TerminalOutput, 'type' | 'id'>) => void;
15
+ onExit: (exit: Omit<TerminalExit, 'type' | 'id'>) => void;
16
+ onStatus: (state: StreamState, message: string) => void;
17
+ }
18
+ export interface TerminalView {
19
+ input(data: string): void;
20
+ resize(cols: number, rows: number): void;
21
+ ack(started: number, offset: number): void;
22
+ restart(): void;
23
+ close(): void;
24
+ }
25
+ export declare class Terminals {
26
+ private readonly logger;
27
+ private readonly terminals;
28
+ constructor(logger: Logger);
29
+ open(request: TerminalRequest, watcher: TerminalWatcher): TerminalView;
30
+ stopAll(): void;
31
+ private hangUpUnseen;
32
+ }