skyffla 1.1.3

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/src/room.js ADDED
@@ -0,0 +1,484 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+
5
+ import {
6
+ ensureMachineProtocolVersion,
7
+ ensureReleaseVersion,
8
+ dumpMessageJson,
9
+ normalizeRoute,
10
+ parseCliVersion,
11
+ parseMachineEvent,
12
+ shouldSkipVersionCheck,
13
+ ChannelKind,
14
+ } from "./protocol.js";
15
+ import {
16
+ SkyfflaProcessExited,
17
+ SkyfflaProtocolError,
18
+ SkyfflaVersionMismatch,
19
+ } from "./errors.js";
20
+
21
+ function defaultBinary() {
22
+ return process.env.SKYFFLA_BIN ?? "skyffla";
23
+ }
24
+
25
+ async function collectOutput(child) {
26
+ const stdout = [];
27
+ const stderr = [];
28
+
29
+ if (child.stdout) {
30
+ for await (const chunk of child.stdout) {
31
+ stdout.push(chunk);
32
+ }
33
+ }
34
+ if (child.stderr) {
35
+ for await (const chunk of child.stderr) {
36
+ stderr.push(chunk);
37
+ }
38
+ }
39
+
40
+ return {
41
+ stdout: Buffer.concat(stdout).toString("utf8").trim(),
42
+ stderr: Buffer.concat(stderr).toString("utf8").trim(),
43
+ };
44
+ }
45
+
46
+ export async function probeBinaryVersion(binary = defaultBinary()) {
47
+ const child = spawn(binary, ["--version"], {
48
+ stdio: ["ignore", "pipe", "pipe"],
49
+ });
50
+ const exitCode = await new Promise((resolve, reject) => {
51
+ child.once("error", reject);
52
+ child.once("exit", resolve);
53
+ }).catch((error) => {
54
+ throw new SkyfflaVersionMismatch(
55
+ `failed to run ${JSON.stringify(binary)} --version: ${error.message}`,
56
+ );
57
+ });
58
+ const output = await collectOutput(child);
59
+ if (exitCode !== 0) {
60
+ throw new SkyfflaVersionMismatch(
61
+ `failed to run ${JSON.stringify(binary)} --version: ${output.stderr || output.stdout}`,
62
+ );
63
+ }
64
+ return parseCliVersion(output.stdout);
65
+ }
66
+
67
+ export async function ensureBinaryVersion(binary = defaultBinary()) {
68
+ if (shouldSkipVersionCheck()) {
69
+ return;
70
+ }
71
+ ensureReleaseVersion(await probeBinaryVersion(binary));
72
+ }
73
+
74
+ class AsyncLineQueue {
75
+ constructor(onClosed) {
76
+ this._items = [];
77
+ this._waiters = [];
78
+ this._closedError = null;
79
+ this._onClosed = onClosed;
80
+ }
81
+
82
+ push(value) {
83
+ const waiter = this._waiters.shift();
84
+ if (waiter) {
85
+ waiter.resolve(value);
86
+ return;
87
+ }
88
+ this._items.push(value);
89
+ }
90
+
91
+ close(error) {
92
+ this._closedError = error;
93
+ while (this._waiters.length > 0) {
94
+ const waiter = this._waiters.shift();
95
+ waiter.reject(error);
96
+ }
97
+ this._onClosed?.(error);
98
+ }
99
+
100
+ shift() {
101
+ if (this._items.length > 0) {
102
+ return Promise.resolve(this._items.shift());
103
+ }
104
+ if (this._closedError) {
105
+ return Promise.reject(this._closedError);
106
+ }
107
+ return new Promise((resolve, reject) => {
108
+ this._waiters.push({ resolve, reject });
109
+ });
110
+ }
111
+ }
112
+
113
+ export class MachineChannel {
114
+ constructor(room, channelId) {
115
+ this.room = room;
116
+ this.channelId = channelId;
117
+ }
118
+
119
+ async send(body) {
120
+ await this.room.sendChannelData(this.channelId, body);
121
+ }
122
+
123
+ async accept() {
124
+ await this.room.acceptChannel(this.channelId);
125
+ }
126
+
127
+ async reject(reason) {
128
+ await this.room.rejectChannel(this.channelId, { reason });
129
+ }
130
+
131
+ async close(reason) {
132
+ await this.room.closeChannel(this.channelId, { reason });
133
+ }
134
+
135
+ async exportFile(path) {
136
+ await this.room.exportChannelFile(this.channelId, path);
137
+ }
138
+ }
139
+
140
+ export class Room {
141
+ constructor({ role, roomId, argv, child }) {
142
+ this.role = role;
143
+ this.roomId = roomId;
144
+ this.argv = Object.freeze([...argv]);
145
+ this._child = child;
146
+ this._stderrLines = [];
147
+ this._readline = createInterface({
148
+ input: child.stdout,
149
+ crlfDelay: Infinity,
150
+ });
151
+ this._queue = new AsyncLineQueue();
152
+
153
+ child.stderr.setEncoding("utf8");
154
+ child.stderr.on("data", (chunk) => {
155
+ for (const line of chunk.split(/\r?\n/u)) {
156
+ if (line !== "") {
157
+ this._stderrLines.push(line);
158
+ }
159
+ }
160
+ });
161
+
162
+ this._readline.on("line", (line) => {
163
+ this._queue.push(line);
164
+ });
165
+
166
+ const closeWithExit = () => {
167
+ this._queue.close(new SkyfflaProcessExited(this._exitMessage("skyffla closed stdout")));
168
+ };
169
+
170
+ this._readline.once("close", closeWithExit);
171
+ child.once("error", (error) => {
172
+ this._queue.close(
173
+ new SkyfflaProcessExited(this._exitMessage(`failed starting skyffla: ${error.message}`)),
174
+ );
175
+ });
176
+ child.once("exit", () => {
177
+ if (!this._queue._closedError) {
178
+ this._queue.close(new SkyfflaProcessExited(this._exitMessage("skyffla exited")));
179
+ }
180
+ });
181
+ }
182
+
183
+ static async host(roomId, options = {}) {
184
+ return spawnRoom("host", roomId, options);
185
+ }
186
+
187
+ static async join(roomId, options = {}) {
188
+ return spawnRoom("join", roomId, options);
189
+ }
190
+
191
+ channel(channelId) {
192
+ return new MachineChannel(this, channelId);
193
+ }
194
+
195
+ async send(command) {
196
+ if (this._child.stdin.destroyed) {
197
+ throw new SkyfflaProcessExited(this._exitMessage("stdin is already closed"));
198
+ }
199
+
200
+ const payload = `${dumpMessageJson(command)}\n`;
201
+ await new Promise((resolve, reject) => {
202
+ this._child.stdin.write(payload, "utf8", (error) => {
203
+ if (error) {
204
+ reject(
205
+ new SkyfflaProcessExited(
206
+ this._exitMessage("failed writing command to skyffla"),
207
+ ),
208
+ );
209
+ return;
210
+ }
211
+ resolve();
212
+ });
213
+ });
214
+ }
215
+
216
+ async sendChat(to, text) {
217
+ await this.send({
218
+ type: "send_chat",
219
+ to: normalizeRoute(to),
220
+ text,
221
+ });
222
+ }
223
+
224
+ async sendFile(channelId, to, path, options = {}) {
225
+ await this.send({
226
+ type: "send_file",
227
+ channel_id: channelId,
228
+ to: normalizeRoute(to),
229
+ path,
230
+ name: options.name,
231
+ mime: options.mime,
232
+ });
233
+ }
234
+
235
+ async openChannel(channelId, options) {
236
+ await this.send({
237
+ type: "open_channel",
238
+ channel_id: channelId,
239
+ kind: options.kind,
240
+ to: normalizeRoute(options.to),
241
+ name: options.name,
242
+ size: options.size,
243
+ mime: options.mime,
244
+ blob: options.blob,
245
+ });
246
+ return this.channel(channelId);
247
+ }
248
+
249
+ async openMachineChannel(channelId, options) {
250
+ return this.openChannel(channelId, {
251
+ kind: ChannelKind.MACHINE,
252
+ ...options,
253
+ });
254
+ }
255
+
256
+ async acceptChannel(channelId) {
257
+ await this.send({
258
+ type: "accept_channel",
259
+ channel_id: channelId,
260
+ });
261
+ }
262
+
263
+ async rejectChannel(channelId, options = {}) {
264
+ await this.send({
265
+ type: "reject_channel",
266
+ channel_id: channelId,
267
+ reason: options.reason,
268
+ });
269
+ }
270
+
271
+ async sendChannelData(channelId, body) {
272
+ await this.send({
273
+ type: "send_channel_data",
274
+ channel_id: channelId,
275
+ body,
276
+ });
277
+ }
278
+
279
+ async closeChannel(channelId, options = {}) {
280
+ await this.send({
281
+ type: "close_channel",
282
+ channel_id: channelId,
283
+ reason: options.reason,
284
+ });
285
+ }
286
+
287
+ async exportChannelFile(channelId, path) {
288
+ await this.send({
289
+ type: "export_channel_file",
290
+ channel_id: channelId,
291
+ path,
292
+ });
293
+ }
294
+
295
+ async recv() {
296
+ while (true) {
297
+ const line = await this._queue.shift();
298
+ const text = line.trim();
299
+ if (text === "") {
300
+ continue;
301
+ }
302
+
303
+ let payload;
304
+ try {
305
+ payload = JSON.parse(text);
306
+ } catch (error) {
307
+ throw new SkyfflaProtocolError(`invalid machine event JSON: ${text}`);
308
+ }
309
+
310
+ const event = parseMachineEvent(payload);
311
+ if (event.type === "room_welcome") {
312
+ ensureMachineProtocolVersion(event.protocol_version);
313
+ }
314
+ return event;
315
+ }
316
+ }
317
+
318
+ async recvUntil(predicate, { timeout = 30_000 } = {}) {
319
+ const deadline = Date.now() + timeout;
320
+ while (true) {
321
+ const remaining = deadline - Date.now();
322
+ if (remaining <= 0) {
323
+ throw new Error(`timed out after ${timeout}ms waiting for machine event`);
324
+ }
325
+ const event = await Promise.race([
326
+ this.recv(),
327
+ delay(remaining).then(() => {
328
+ throw new Error(`timed out after ${timeout}ms waiting for machine event`);
329
+ }),
330
+ ]);
331
+ if (predicate(event)) {
332
+ return event;
333
+ }
334
+ }
335
+ }
336
+
337
+ async waitForWelcome(options = {}) {
338
+ return this.recvUntil((event) => event.type === "room_welcome", options);
339
+ }
340
+
341
+ async waitForMemberSnapshot({ minMembers = 1, timeout = 30_000 } = {}) {
342
+ return this.recvUntil(
343
+ (event) => event.type === "member_snapshot" && event.members.length >= minMembers,
344
+ { timeout },
345
+ );
346
+ }
347
+
348
+ async waitForMemberJoined({ name, timeout = 30_000 } = {}) {
349
+ return this.recvUntil(
350
+ (event) =>
351
+ event.type === "member_joined" && (name === undefined || event.member.name === name),
352
+ { timeout },
353
+ );
354
+ }
355
+
356
+ async waitForChat({ text, fromName, timeout = 30_000 } = {}) {
357
+ return this.recvUntil(
358
+ (event) =>
359
+ event.type === "chat" &&
360
+ (text === undefined || event.text === text) &&
361
+ (fromName === undefined || event.from_name === fromName),
362
+ { timeout },
363
+ );
364
+ }
365
+
366
+ async waitForChannelOpened({ channelId, timeout = 30_000 } = {}) {
367
+ return this.recvUntil(
368
+ (event) =>
369
+ event.type === "channel_opened" &&
370
+ (channelId === undefined || event.channel_id === channelId),
371
+ { timeout },
372
+ );
373
+ }
374
+
375
+ async waitForChannelData({ channelId, timeout = 30_000 } = {}) {
376
+ return this.recvUntil(
377
+ (event) =>
378
+ event.type === "channel_data" &&
379
+ (channelId === undefined || event.channel_id === channelId),
380
+ { timeout },
381
+ );
382
+ }
383
+
384
+ stderrLines() {
385
+ return Object.freeze([...this._stderrLines]);
386
+ }
387
+
388
+ async wait() {
389
+ if (this._child.exitCode !== null) {
390
+ return this._child.exitCode;
391
+ }
392
+ return new Promise((resolve) => {
393
+ this._child.once("exit", (code) => resolve(code ?? 0));
394
+ });
395
+ }
396
+
397
+ async close() {
398
+ if (!this._child.stdin.destroyed) {
399
+ this._child.stdin.end();
400
+ }
401
+
402
+ if (this._child.exitCode === null) {
403
+ this._child.kill("SIGTERM");
404
+ const exited = await Promise.race([
405
+ this.wait().then(() => true),
406
+ delay(5_000).then(() => false),
407
+ ]);
408
+ if (!exited && this._child.exitCode === null) {
409
+ this._child.kill("SIGKILL");
410
+ await this.wait();
411
+ }
412
+ }
413
+
414
+ this._readline.close();
415
+ }
416
+
417
+ [Symbol.asyncIterator]() {
418
+ return {
419
+ next: async () => {
420
+ try {
421
+ const value = await this.recv();
422
+ return { value, done: false };
423
+ } catch (error) {
424
+ if (error instanceof SkyfflaProcessExited) {
425
+ return { value: undefined, done: true };
426
+ }
427
+ throw error;
428
+ }
429
+ },
430
+ };
431
+ }
432
+
433
+ _exitMessage(detail) {
434
+ let message = `${detail}; argv=${JSON.stringify(this.argv)}`;
435
+ if (this._child.exitCode !== null) {
436
+ message += `; returncode=${this._child.exitCode}`;
437
+ }
438
+ if (this._stderrLines.length > 0) {
439
+ message += `\nstderr:\n${this._stderrLines.slice(-20).join("\n")}`;
440
+ }
441
+ return message;
442
+ }
443
+ }
444
+
445
+ async function spawnRoom(role, roomId, options) {
446
+ const binary = options.binary ?? defaultBinary();
447
+ await ensureBinaryVersion(binary);
448
+
449
+ const argv = [role, roomId, "machine", "--json"];
450
+ if (options.server !== undefined) {
451
+ argv.push("--server", options.server);
452
+ }
453
+ if (options.name !== undefined) {
454
+ argv.push("--name", options.name);
455
+ }
456
+ if (options.downloadDir !== undefined) {
457
+ argv.push("--download-dir", options.downloadDir);
458
+ }
459
+ if (options.local) {
460
+ argv.push("--local");
461
+ }
462
+
463
+ const child = spawn(binary, argv, {
464
+ stdio: ["pipe", "pipe", "pipe"],
465
+ });
466
+
467
+ await new Promise((resolve, reject) => {
468
+ child.once("spawn", resolve);
469
+ child.once("error", (error) => {
470
+ reject(
471
+ new SkyfflaProcessExited(
472
+ `failed starting skyffla process ${JSON.stringify(binary)}: ${error.message}`,
473
+ ),
474
+ );
475
+ });
476
+ });
477
+
478
+ return new Room({
479
+ role,
480
+ roomId,
481
+ argv: [binary, ...argv],
482
+ child,
483
+ });
484
+ }
package/src/version.js ADDED
@@ -0,0 +1 @@
1
+ export const __version__ = "1.1.3";