tassello 0.1.0 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +66 -3
  3. package/dist/server/behavior_tree_file.d.ts +17 -0
  4. package/dist/server/behavior_tree_file.js +232 -0
  5. package/dist/server/behavior_tree_files.d.ts +18 -0
  6. package/dist/server/behavior_tree_files.js +405 -0
  7. package/dist/server/behavior_tree_log.d.ts +10 -0
  8. package/dist/server/behavior_tree_log.js +132 -0
  9. package/dist/server/behavior_tree_playback.d.ts +57 -0
  10. package/dist/server/behavior_tree_playback.js +188 -0
  11. package/dist/server/behavior_tree_state.d.ts +38 -0
  12. package/dist/server/behavior_tree_state.js +220 -0
  13. package/dist/server/behavior_trees.d.ts +29 -0
  14. package/dist/server/behavior_trees.js +362 -0
  15. package/dist/server/files.d.ts +5 -2
  16. package/dist/server/files.js +32 -13
  17. package/dist/server/http.js +7 -5
  18. package/dist/server/rosbag.d.ts +25 -0
  19. package/dist/server/rosbag.js +418 -0
  20. package/dist/server/rosout.d.ts +16 -0
  21. package/dist/server/rosout.js +203 -0
  22. package/dist/server/rosout_log.d.ts +20 -0
  23. package/dist/server/rosout_log.js +66 -0
  24. package/dist/server/server.js +20 -4
  25. package/dist/server/session.d.ts +7 -0
  26. package/dist/server/session.js +63 -0
  27. package/dist/server/systemd.js +1 -1
  28. package/dist/server/systemd_format.d.ts +1 -0
  29. package/dist/server/systemd_format.js +19 -3
  30. package/dist/shared/bt_nodes.d.ts +25 -0
  31. package/dist/shared/bt_nodes.js +180 -0
  32. package/dist/shared/protocol.d.ts +157 -2
  33. package/dist/shared/protocol.js +4 -0
  34. package/dist/shared/xml.d.ts +25 -0
  35. package/dist/shared/xml.js +164 -0
  36. package/dist/types/client/api.d.ts +4 -3
  37. package/dist/types/client/connection.d.ts +31 -1
  38. package/dist/types/client/dialogs.d.ts +2 -1
  39. package/dist/types/client/icons.d.ts +24 -0
  40. package/dist/types/client/sdk/index.d.ts +3 -2
  41. package/dist/types/client/widgets/types.d.ts +5 -1
  42. package/dist/types/shared/bt_nodes.d.ts +25 -0
  43. package/dist/types/shared/protocol.d.ts +157 -2
  44. package/dist/www/assets/Joystick.js +1 -1
  45. package/dist/www/assets/RobotModelView.js +2 -2
  46. package/dist/www/assets/icons.js +1 -1
  47. package/dist/www/assets/index.css +1 -1
  48. package/dist/www/assets/index.js +28 -1
  49. package/dist/www/assets/shared/tassello-sdk.js +1 -1
  50. package/docs/plugins.md +8 -0
  51. package/package.json +2 -1
@@ -0,0 +1,362 @@
1
+ // The logs of behavior trees (nav2_msgs/msg/BehaviorTreeLog), for the
2
+ // behavior tree inspector widgets: the tree a widget names (its XML file, see
3
+ // behavior_tree_file.ts) and what its nodes are doing (behavior_tree_state.ts).
4
+ //
5
+ // A log is a stream of transitions, not of states: none of them may be lost,
6
+ // unlike the latest-only messages of topics.ts. Each topic is subscribed once,
7
+ // with its publishers' QoS and a deep queue; each tree of it keeps its nodes'
8
+ // state, sent to its pages as it changes, at most BT_RATE a second. The state
9
+ // outlives its last page for LINGER_MS: a page reloaded, or a panel shown
10
+ // again, finds the nodes as they are, which the log only says when they change.
11
+ //
12
+ // A topic is recorded into a rosbag (ros2 bag record) when a page asks, until
13
+ // one asks to stop: every page following it is told. A bag is played back for
14
+ // the page that opens it (behavior_tree_playback.ts).
15
+ import { promises as fs } from 'node:fs';
16
+ import { BT_RATE } from '../shared/protocol.js';
17
+ import { readTree } from './behavior_tree_file.js';
18
+ import { logTransitions } from './behavior_tree_log.js';
19
+ import { BehaviorTreePlayback } from './behavior_tree_playback.js';
20
+ import { BehaviorTreeState } from './behavior_tree_state.js';
21
+ import { publishersQos } from './qos.js';
22
+ import { BagRecorder, newBagFolder } from './rosbag.js';
23
+ import { absoluteTopic, errorMessage } from './util.js';
24
+ // How often the graph is checked for the topic's publishers, until it has one
25
+ const RETRY_PERIOD_MS = 1000;
26
+ // Messages queued while the server is busy: a tree logs bursts of them
27
+ const QUEUE_DEPTH = 100;
28
+ const LINGER_MS = 10 * 60_000;
29
+ // How often the tree's files are checked for changes, while watched
30
+ const FILE_CHECK_MS = 3000;
31
+ // The subscription to a topic, shared by its trees
32
+ class LogTopic {
33
+ listeners = new Set();
34
+ node;
35
+ logger;
36
+ topic;
37
+ subscription = null;
38
+ retryTimer = null;
39
+ state = 'waiting';
40
+ message = '';
41
+ constructor(node, logger, topic) {
42
+ this.node = node;
43
+ this.logger = logger;
44
+ this.topic = topic;
45
+ this.subscribe();
46
+ }
47
+ add(listener) {
48
+ this.listeners.add(listener);
49
+ listener.onStatus(this.state, this.message);
50
+ }
51
+ destroy() {
52
+ if (this.retryTimer) {
53
+ clearTimeout(this.retryTimer);
54
+ }
55
+ if (this.subscription) {
56
+ this.node.destroySubscription(this.subscription);
57
+ }
58
+ }
59
+ subscribe() {
60
+ this.retryTimer = null;
61
+ const match = publishersQos(this.node, this.topic, null, QUEUE_DEPTH);
62
+ if (!('qos' in match)) {
63
+ this.setState(match.state, match.message);
64
+ this.retryTimer = setTimeout(() => this.subscribe(), RETRY_PERIOD_MS);
65
+ return;
66
+ }
67
+ try {
68
+ this.subscription = this.node.createSubscription(
69
+ // @ts-expect-error: the type is a runtime string, not one of the literal types
70
+ match.type, this.topic, { qos: match.qos }, (raw) => this.onMessage(raw, match.type));
71
+ }
72
+ catch (e) {
73
+ this.setState('error', `Cannot subscribe to ${this.topic}: ${errorMessage(e)}`);
74
+ return;
75
+ }
76
+ this.setState('waiting', `Waiting for transitions on ${this.topic}`);
77
+ }
78
+ onMessage(raw, type) {
79
+ let transitions;
80
+ try {
81
+ transitions = logTransitions(raw);
82
+ }
83
+ catch (e) {
84
+ this.setState('error', `${this.topic} is a ${type}, ${errorMessage(e)}`);
85
+ return;
86
+ }
87
+ this.setState('streaming', type);
88
+ for (const listener of this.listeners) {
89
+ listener.onTransitions(transitions);
90
+ }
91
+ }
92
+ setState(state, message) {
93
+ if (state === this.state && message === this.message) {
94
+ return;
95
+ }
96
+ if (state === 'error') {
97
+ this.logger.warn(message);
98
+ }
99
+ this.state = state;
100
+ this.message = message;
101
+ for (const listener of this.listeners) {
102
+ listener.onStatus(state, message);
103
+ }
104
+ }
105
+ }
106
+ // A tree of a topic: its file, its nodes' state, its pages
107
+ class TreeMonitor {
108
+ watchers = new Set();
109
+ lingerTimer = null;
110
+ source;
111
+ uploadsDir;
112
+ logger;
113
+ tree = null;
114
+ recording;
115
+ state = new BehaviorTreeState([]);
116
+ // Transitions that came while the tree was read, applied after
117
+ queued = [];
118
+ // The files read, and their versions then
119
+ versions = new Map();
120
+ status = { state: 'waiting', message: '' };
121
+ flushTimer = null;
122
+ lastFlush = 0;
123
+ fileTimer = null;
124
+ destroyed = false;
125
+ constructor(source, uploadsDir, logger, recording) {
126
+ this.source = source;
127
+ this.uploadsDir = uploadsDir;
128
+ this.logger = logger;
129
+ this.recording = recording;
130
+ void this.load();
131
+ }
132
+ add(watcher) {
133
+ if (this.lingerTimer) {
134
+ clearTimeout(this.lingerTimer);
135
+ this.lingerTimer = null;
136
+ }
137
+ // What the others have not had yet, which the new one gets with the rest
138
+ this.flush();
139
+ this.watchers.add(watcher);
140
+ watcher.onStatus(this.status.state, this.status.message);
141
+ watcher.onRecording(this.recording);
142
+ if (this.tree) {
143
+ watcher.onTree(this.tree);
144
+ watcher.onUpdate({ reset: true, ...this.state.snapshot() });
145
+ }
146
+ this.fileTimer ??= setInterval(() => void this.checkFiles(), FILE_CHECK_MS);
147
+ }
148
+ remove(watcher) {
149
+ this.watchers.delete(watcher);
150
+ if (this.watchers.size === 0 && this.fileTimer) {
151
+ clearInterval(this.fileTimer);
152
+ this.fileTimer = null;
153
+ }
154
+ }
155
+ destroy() {
156
+ this.destroyed = true;
157
+ for (const timer of [this.flushTimer, this.lingerTimer]) {
158
+ if (timer) {
159
+ clearTimeout(timer);
160
+ }
161
+ }
162
+ if (this.fileTimer) {
163
+ clearInterval(this.fileTimer);
164
+ }
165
+ }
166
+ setRecording(recording) {
167
+ this.recording = recording;
168
+ for (const watcher of this.watchers) {
169
+ watcher.onRecording(recording);
170
+ }
171
+ }
172
+ onStatus(state, message) {
173
+ this.status = { state, message };
174
+ for (const watcher of this.watchers) {
175
+ watcher.onStatus(state, message);
176
+ }
177
+ }
178
+ onTransitions(transitions) {
179
+ const now = Date.now();
180
+ if (this.queued) {
181
+ this.queued.push({ transitions, at: now });
182
+ return;
183
+ }
184
+ for (const transition of transitions) {
185
+ this.state.apply(transition, now);
186
+ }
187
+ this.scheduleFlush();
188
+ }
189
+ async load() {
190
+ const { tree, files } = await readTree(this.source, this.uploadsDir, this.logger);
191
+ if (this.destroyed) {
192
+ return;
193
+ }
194
+ this.versions = new Map(await Promise.all(files.map(async (file) => [file, await version(file)])));
195
+ this.tree = tree;
196
+ this.state = new BehaviorTreeState(tree.nodes);
197
+ for (const { transitions, at } of this.queued ?? []) {
198
+ for (const transition of transitions) {
199
+ this.state.apply(transition, at);
200
+ }
201
+ }
202
+ this.queued = null;
203
+ for (const watcher of this.watchers) {
204
+ watcher.onTree(tree);
205
+ watcher.onUpdate({ reset: true, ...this.state.snapshot() });
206
+ }
207
+ }
208
+ // The tree is read again when one of its files changed: its pages start over
209
+ async checkFiles() {
210
+ if (this.queued || this.versions.size === 0) {
211
+ return;
212
+ }
213
+ for (const [file, known] of this.versions) {
214
+ if ((await version(file)) !== known) {
215
+ this.queued = [];
216
+ await this.load();
217
+ return;
218
+ }
219
+ }
220
+ }
221
+ scheduleFlush() {
222
+ if (this.flushTimer || this.watchers.size === 0) {
223
+ return;
224
+ }
225
+ const wait = Math.max(0, this.lastFlush + 1000 / BT_RATE - Date.now());
226
+ this.flushTimer = setTimeout(() => this.flush(), wait);
227
+ }
228
+ flush() {
229
+ if (this.flushTimer) {
230
+ clearTimeout(this.flushTimer);
231
+ this.flushTimer = null;
232
+ }
233
+ this.lastFlush = Date.now();
234
+ const changes = this.state.takeChanges();
235
+ if (!changes) {
236
+ return;
237
+ }
238
+ for (const watcher of this.watchers) {
239
+ watcher.onUpdate({ reset: false, ...changes });
240
+ }
241
+ }
242
+ }
243
+ // Size and time of a file, '' if gone
244
+ async function version(file) {
245
+ const stat = await fs.stat(file).catch(() => null);
246
+ return stat ? `${stat.size}-${stat.mtimeMs}` : '';
247
+ }
248
+ export class BehaviorTrees {
249
+ node;
250
+ logger;
251
+ uploadsDir;
252
+ topics = new Map();
253
+ // By topic, then tree source
254
+ monitors = new Map();
255
+ // By topic
256
+ recordings = new Map();
257
+ constructor(node, logger, uploadsDir) {
258
+ this.node = node;
259
+ this.logger = logger;
260
+ this.uploadsDir = uploadsDir;
261
+ }
262
+ // Until the returned function is called
263
+ watch(topic, tree, watcher) {
264
+ const name = absoluteTopic(topic);
265
+ const source = tree.trim();
266
+ let trees = this.monitors.get(name);
267
+ if (!trees) {
268
+ trees = new Map();
269
+ this.monitors.set(name, trees);
270
+ }
271
+ let monitor = trees.get(source);
272
+ if (!monitor) {
273
+ monitor = new TreeMonitor(source, this.uploadsDir, this.logger, this.recordingOf(name));
274
+ trees.set(source, monitor);
275
+ let log = this.topics.get(name);
276
+ if (!log) {
277
+ log = new LogTopic(this.node, this.logger, name);
278
+ this.topics.set(name, log);
279
+ }
280
+ log.add(monitor);
281
+ }
282
+ const shared = monitor;
283
+ shared.add(watcher);
284
+ return () => {
285
+ shared.remove(watcher);
286
+ if (shared.watchers.size === 0 && !shared.lingerTimer) {
287
+ shared.lingerTimer = setTimeout(() => this.forget(name, source, shared), LINGER_MS);
288
+ }
289
+ };
290
+ }
291
+ // Records the topic into a new folder, or stops (path null): resolves with
292
+ // the bag's folder, null if it was not recording
293
+ async record(topic, path) {
294
+ const name = absoluteTopic(topic);
295
+ const current = this.recordings.get(name);
296
+ if (path === null) {
297
+ if (!current?.recorder) {
298
+ return null;
299
+ }
300
+ this.recordings.delete(name);
301
+ this.announce(name);
302
+ await current.recorder.stop();
303
+ this.logger.info(`Recorded ${name} into ${current.path}`);
304
+ return current.path;
305
+ }
306
+ if (current) {
307
+ throw new Error(`${name} is being recorded already, into ${current.path}`);
308
+ }
309
+ const recording = { path: '', since: 0, recorder: null };
310
+ this.recordings.set(name, recording);
311
+ try {
312
+ recording.path = await newBagFolder(path);
313
+ recording.recorder = await BagRecorder.start(recording.path, [name]);
314
+ }
315
+ catch (e) {
316
+ this.recordings.delete(name);
317
+ throw new Error(`Cannot record ${name}: ${errorMessage(e)}`);
318
+ }
319
+ recording.since = Date.now();
320
+ recording.recorder.onExit = (reason) => {
321
+ if (this.recordings.get(name) === recording) {
322
+ this.recordings.delete(name);
323
+ this.announce(name);
324
+ this.logger.warn(`Recording ${name} stopped: ${reason}`);
325
+ }
326
+ };
327
+ this.logger.info(`Recording ${name} into ${recording.path}`);
328
+ this.announce(name);
329
+ return recording.path;
330
+ }
331
+ // A bag played back for a page, until closed
332
+ play(bag, topic, tree, watcher) {
333
+ return new BehaviorTreePlayback({ bag, topic, tree: tree.trim(), uploadsDir: this.uploadsDir, logger: this.logger }, watcher);
334
+ }
335
+ // The recordings completed, as the server stops
336
+ async stopAll() {
337
+ const recorders = [...this.recordings.values()].flatMap((recording) => recording.recorder ?? []);
338
+ this.recordings.clear();
339
+ await Promise.all(recorders.map((recorder) => recorder.stop()));
340
+ }
341
+ recordingOf(topic) {
342
+ const recording = this.recordings.get(topic);
343
+ return recording?.recorder ? { path: recording.path, since: recording.since } : null;
344
+ }
345
+ announce(topic) {
346
+ for (const monitor of this.monitors.get(topic)?.values() ?? []) {
347
+ monitor.setRecording(this.recordingOf(topic));
348
+ }
349
+ }
350
+ forget(topic, source, monitor) {
351
+ monitor.destroy();
352
+ const trees = this.monitors.get(topic);
353
+ trees?.delete(source);
354
+ const log = this.topics.get(topic);
355
+ log?.listeners.delete(monitor);
356
+ if (trees?.size === 0) {
357
+ this.monitors.delete(topic);
358
+ log?.destroy();
359
+ this.topics.delete(topic);
360
+ }
361
+ }
362
+ }
@@ -1,5 +1,7 @@
1
1
  export declare const IMAGE_EXTENSIONS: Set<string>;
2
+ export type FileKind = 'image' | 'behavior-tree' | 'bag' | 'folder';
2
3
  export declare const MAX_UPLOAD_BYTES: number;
4
+ export declare function fileKind(value: string | null): FileKind;
3
5
  export declare function resolveImagePath(input: string, home?: string): string;
4
6
  export declare function imageType(data: Buffer): string | null;
5
7
  export interface DirectoryListing {
@@ -7,9 +9,10 @@ export interface DirectoryListing {
7
9
  parent: string | null;
8
10
  entries: {
9
11
  name: string;
10
- kind: 'dir' | 'image';
12
+ kind: 'dir' | FileKind;
11
13
  }[];
12
14
  }
13
- export declare function listDirectory(input: string, home?: string): Promise<DirectoryListing>;
15
+ export declare function listDirectory(input: string, kind?: FileKind, home?: string): Promise<DirectoryListing>;
14
16
  export declare function saveUpload(uploadsDir: string, data: Buffer): Promise<string>;
17
+ export declare function isBehaviorTreeXml(text: string): boolean;
15
18
  export declare function isUploadName(name: string): boolean;
@@ -1,16 +1,29 @@
1
- // Images for the image viewer widgets: files of the robot, which a page picks
2
- // by browsing its directories, and images uploaded from a page, kept by the
3
- // server next to the panels. Only images: the dashboard does not hand out any
4
- // file of the robot.
1
+ // Images for the image viewer widgets, and behavior trees (XML) for the
2
+ // behavior tree inspectors: files of the robot, which a page picks by
3
+ // browsing its directories, and files uploaded from a page, kept by the
4
+ // server next to the panels. Only those: the dashboard does not hand out any
5
+ // file of the robot. A page also browses for rosbags, and for folders.
5
6
  import { createHash } from 'node:crypto';
6
7
  import { promises as fs } from 'node:fs';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
10
  import { HttpError } from './panels.js';
10
11
  export const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
12
+ const EXTENSIONS = {
13
+ image: IMAGE_EXTENSIONS,
14
+ 'behavior-tree': new Set(['.xml']),
15
+ bag: new Set(['.mcap', '.db3']),
16
+ folder: new Set(),
17
+ };
18
+ const FILE_KINDS = new Set(Object.keys(EXTENSIONS));
11
19
  export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
12
20
  // Uploaded files are named after their content: no clash, no duplicate
13
- const UPLOAD_NAME = /^[0-9a-f]{16}\.(png|jpg|gif|webp|bmp|svg)$/;
21
+ const UPLOAD_NAME = /^[0-9a-f]{16}\.(png|jpg|gif|webp|bmp|svg|xml)$/;
22
+ // BT.CPP's XML, whose root element is <root>
23
+ const BEHAVIOR_TREE = /^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*<root[\s>]/;
24
+ export function fileKind(value) {
25
+ return value && FILE_KINDS.has(value) ? value : 'image';
26
+ }
14
27
  // A robot path (absolute or ~/...) to an image file; throws for anything else
15
28
  export function resolveImagePath(input, home = os.homedir()) {
16
29
  const trimmed = input.trim();
@@ -48,8 +61,8 @@ export function imageType(data) {
48
61
  }
49
62
  return null;
50
63
  }
51
- // Of the directory (~ by default)
52
- export async function listDirectory(input, home = os.homedir()) {
64
+ // Of the directory (~ by default): its directories and files of that kind
65
+ export async function listDirectory(input, kind = 'image', home = os.homedir()) {
53
66
  const trimmed = input.trim() || '~';
54
67
  const expanded = trimmed === '~' || trimmed.startsWith('~/') ? path.join(home, trimmed.slice(1)) : trimmed;
55
68
  if (!path.isAbsolute(expanded)) {
@@ -74,26 +87,32 @@ export async function listDirectory(input, home = os.homedir()) {
74
87
  ? await fs.stat(path.join(dir, entry.name)).catch(() => null)
75
88
  : entry;
76
89
  if (target?.isDirectory()) {
77
- entries.push({ name: entry.name, kind: 'dir' });
90
+ const bag = kind === 'bag' && (await fs.stat(path.join(dir, entry.name, 'metadata.yaml')).catch(() => null));
91
+ entries.push({ name: entry.name, kind: bag ? 'bag' : 'dir' });
78
92
  }
79
- else if (target?.isFile() && IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
80
- entries.push({ name: entry.name, kind: 'image' });
93
+ else if (target?.isFile() && EXTENSIONS[kind].has(path.extname(entry.name).toLowerCase())) {
94
+ entries.push({ name: entry.name, kind });
81
95
  }
82
96
  }
83
97
  entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1));
84
98
  return { dir, parent: dir === path.parse(dir).root ? null : path.dirname(dir), entries };
85
99
  }
86
- // Saves an uploaded image; returns its name in the uploads directory
100
+ // Saves an uploaded image or behavior tree; returns its name in the uploads
101
+ // directory
87
102
  export async function saveUpload(uploadsDir, data) {
88
- const type = imageType(data);
103
+ const type = imageType(data) ?? (isBehaviorTreeXml(data.subarray(0, 64 * 1024).toString('utf8')) ? 'xml' : null);
89
104
  if (!type) {
90
- throw new HttpError(415, 'Not an image: PNG, JPEG, GIF, WebP, BMP or SVG');
105
+ throw new HttpError(415, 'Not an image (PNG, JPEG, GIF, WebP, BMP, SVG) nor a behavior tree (XML)');
91
106
  }
92
107
  const name = `${createHash('sha256').update(data).digest('hex').slice(0, 16)}.${type}`;
93
108
  await fs.mkdir(uploadsDir, { recursive: true });
94
109
  await fs.writeFile(path.join(uploadsDir, name), data);
95
110
  return name;
96
111
  }
112
+ // Whether the text is BT.CPP's XML, by its first element: <root>
113
+ export function isBehaviorTreeXml(text) {
114
+ return BEHAVIOR_TREE.test(text.slice(0, 64 * 1024).trimStart());
115
+ }
97
116
  export function isUploadName(name) {
98
117
  return UPLOAD_NAME.test(name);
99
118
  }
@@ -12,9 +12,10 @@
12
12
  // directory, as package://<package>/<path> names it: the meshes of
13
13
  // the robot description
14
14
  // GET /api/files?path=<path> an image of the robot (absolute or ~/ path)
15
- // GET /api/files/list?dir=<dir> its subdirectories and images
16
- // POST /api/uploads an image (the body) -> {name}
17
- // GET /api/uploads/<name> an uploaded image
15
+ // GET /api/files/list?dir=<dir>[&kind=behavior-tree]
16
+ // its subdirectories and images (or XML files)
17
+ // POST /api/uploads an image or a behavior tree (the body) -> {name}
18
+ // GET /api/uploads/<name> an uploaded file
18
19
  // GET /api/plugins [PluginInfo] of the plugins loaded
19
20
  // GET /plugins/<name>/<path> a file of the plugin's client folder
20
21
  import { createReadStream, promises as fs } from 'node:fs';
@@ -22,7 +23,7 @@ import path from 'node:path';
22
23
  import { promisify } from 'node:util';
23
24
  import { gzip } from 'node:zlib';
24
25
  import { DASHBOARD_CLIENT_HEADER } from '../shared/protocol.js';
25
- import { isUploadName, listDirectory, MAX_UPLOAD_BYTES, resolveImagePath, saveUpload } from './files.js';
26
+ import { fileKind, isUploadName, listDirectory, MAX_UPLOAD_BYTES, resolveImagePath, saveUpload } from './files.js';
26
27
  import { HttpError } from './panels.js';
27
28
  import { errorMessage, packageShareDirectory } from './util.js';
28
29
  // By lowercase extension: meshes come as .STL as often as .stl
@@ -38,6 +39,7 @@ const CONTENT_TYPES = {
38
39
  '.jpeg': 'image/jpeg',
39
40
  '.gif': 'image/gif',
40
41
  '.webp': 'image/webp',
42
+ '.xml': 'application/xml; charset=utf-8',
41
43
  '.bmp': 'image/bmp',
42
44
  '.ico': 'image/x-icon',
43
45
  '.woff2': 'font/woff2',
@@ -143,7 +145,7 @@ async function api({ store, topics, uploadsDir, plugins }, req, res, route) {
143
145
  return serveFile(path.dirname(file), path.basename(file), req, res, SANDBOXED);
144
146
  }
145
147
  if (id === 'list' && rest.length === 0) {
146
- return sendJson(res, 200, await listDirectory(query.get('dir') ?? ''));
148
+ return sendJson(res, 200, await listDirectory(query.get('dir') ?? '', fileKind(query.get('kind'))));
147
149
  }
148
150
  throw new HttpError(404, `No API at ${req.url}`);
149
151
  }
@@ -0,0 +1,25 @@
1
+ export interface BagTopic {
2
+ name: string;
3
+ type: string;
4
+ }
5
+ export interface BagMessage {
6
+ time: number;
7
+ data: Uint8Array;
8
+ }
9
+ export type BagTopics = Map<string, {
10
+ topic: BagTopic;
11
+ messages: BagMessage[];
12
+ }>;
13
+ export declare function bagFiles(input: string, home?: string): Promise<string[]>;
14
+ export declare function readBag(files: string[], accept: (topic: BagTopic) => boolean): Promise<BagTopics>;
15
+ export declare function newBagFolder(input: string, home?: string): Promise<string>;
16
+ export declare class BagRecorder {
17
+ readonly folder: string;
18
+ onExit: ((reason: string) => void) | null;
19
+ private readonly child;
20
+ private output;
21
+ private stopping;
22
+ private constructor();
23
+ static start(folder: string, topics: string[]): Promise<BagRecorder>;
24
+ stop(): Promise<void>;
25
+ }