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/PUBLISHING.md +79 -0
- package/README.md +118 -0
- package/package.json +38 -0
- package/src/errors.js +14 -0
- package/src/index.d.ts +336 -0
- package/src/index.js +33 -0
- package/src/protocol.js +427 -0
- package/src/room.js +484 -0
- package/src/version.js +1 -0
package/src/protocol.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SkyfflaMachineProtocolMismatch,
|
|
3
|
+
SkyfflaProtocolError,
|
|
4
|
+
SkyfflaVersionMismatch,
|
|
5
|
+
} from "./errors.js";
|
|
6
|
+
import { __version__ } from "./version.js";
|
|
7
|
+
|
|
8
|
+
export const ChannelKind = Object.freeze({
|
|
9
|
+
MACHINE: "machine",
|
|
10
|
+
FILE: "file",
|
|
11
|
+
CLIPBOARD: "clipboard",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const BlobFormat = Object.freeze({
|
|
15
|
+
BLOB: "blob",
|
|
16
|
+
COLLECTION: "collection",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const TransferItemKind = Object.freeze({
|
|
20
|
+
FILE: "file",
|
|
21
|
+
FOLDER: "folder",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const TransferPhase = Object.freeze({
|
|
25
|
+
PREPARING: "preparing",
|
|
26
|
+
DOWNLOADING: "downloading",
|
|
27
|
+
EXPORTING: "exporting",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const MACHINE_PROTOCOL_VERSION = Object.freeze({
|
|
31
|
+
major: 1,
|
|
32
|
+
minor: 0,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const ROUTE_TYPES = new Set(["all", "member"]);
|
|
36
|
+
const CHANNEL_KINDS = new Set(Object.values(ChannelKind));
|
|
37
|
+
const BLOB_FORMATS = new Set(Object.values(BlobFormat));
|
|
38
|
+
const TRANSFER_ITEM_KINDS = new Set(Object.values(TransferItemKind));
|
|
39
|
+
const TRANSFER_PHASES = new Set(Object.values(TransferPhase));
|
|
40
|
+
const MACHINE_COMMAND_TYPES = new Set([
|
|
41
|
+
"send_chat",
|
|
42
|
+
"send_file",
|
|
43
|
+
"open_channel",
|
|
44
|
+
"accept_channel",
|
|
45
|
+
"reject_channel",
|
|
46
|
+
"send_channel_data",
|
|
47
|
+
"close_channel",
|
|
48
|
+
"export_channel_file",
|
|
49
|
+
]);
|
|
50
|
+
const VERSION_RE = /\b(\d+\.\d+\.\d+)\b/;
|
|
51
|
+
|
|
52
|
+
function requireObject(value, fieldName) {
|
|
53
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
54
|
+
throw new SkyfflaProtocolError(`${fieldName} must be an object`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireString(value, fieldName) {
|
|
60
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
61
|
+
throw new SkyfflaProtocolError(`${fieldName} must be a non-empty string`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function requireBody(value, fieldName) {
|
|
67
|
+
if (typeof value !== "string" || value === "") {
|
|
68
|
+
throw new SkyfflaProtocolError(`${fieldName} must be a non-empty string`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function optionalString(value, fieldName) {
|
|
74
|
+
if (value === undefined) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return requireString(value, fieldName);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function optionalInteger(value, fieldName, { min = 0 } = {}) {
|
|
81
|
+
if (value === undefined || value === null) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
if (!Number.isInteger(value) || value < min) {
|
|
85
|
+
throw new SkyfflaProtocolError(`${fieldName} must be an integer >= ${min}`);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireInteger(value, fieldName, { min = 0 } = {}) {
|
|
91
|
+
if (!Number.isInteger(value) || value < min) {
|
|
92
|
+
throw new SkyfflaProtocolError(`${fieldName} must be an integer >= ${min}`);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requireEnum(value, fieldName, allowed) {
|
|
98
|
+
if (typeof value !== "string" || !allowed.has(value)) {
|
|
99
|
+
throw new SkyfflaProtocolError(
|
|
100
|
+
`${fieldName} must be one of: ${Array.from(allowed).join(", ")}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function copyDefined(entries) {
|
|
107
|
+
return Object.fromEntries(entries.filter(([, value]) => value !== undefined));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseBlobRef(value, fieldName = "blob") {
|
|
111
|
+
const blob = requireObject(value, fieldName);
|
|
112
|
+
return Object.freeze({
|
|
113
|
+
hash: requireString(blob.hash, `${fieldName}.hash`),
|
|
114
|
+
format: requireEnum(blob.format, `${fieldName}.format`, BLOB_FORMATS),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function routeAll() {
|
|
119
|
+
return Object.freeze({ type: "all" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function routeMember(memberId) {
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
type: "member",
|
|
125
|
+
member_id: requireString(memberId, "member_id"),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function normalizeRoute(route) {
|
|
130
|
+
if (route === "all") {
|
|
131
|
+
return routeAll();
|
|
132
|
+
}
|
|
133
|
+
if (typeof route === "string") {
|
|
134
|
+
return routeMember(route);
|
|
135
|
+
}
|
|
136
|
+
return parseRoute(route);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseRoute(value) {
|
|
140
|
+
const route = requireObject(value, "route");
|
|
141
|
+
const type = requireEnum(route.type, "route.type", ROUTE_TYPES);
|
|
142
|
+
if (type === "all") {
|
|
143
|
+
return routeAll();
|
|
144
|
+
}
|
|
145
|
+
return routeMember(route.member_id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseProtocolVersion(value, fieldName = "protocol_version") {
|
|
149
|
+
const version = requireObject(value, fieldName);
|
|
150
|
+
return Object.freeze({
|
|
151
|
+
major: requireInteger(version.major, `${fieldName}.major`, { min: 1 }),
|
|
152
|
+
minor: requireInteger(version.minor, `${fieldName}.minor`, { min: 0 }),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function isProtocolCompatible(version, other) {
|
|
157
|
+
return version.major === other.major;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function ensureMachineProtocolVersion(version) {
|
|
161
|
+
const parsed = parseProtocolVersion(version);
|
|
162
|
+
if (!isProtocolCompatible(parsed, MACHINE_PROTOCOL_VERSION)) {
|
|
163
|
+
throw new SkyfflaMachineProtocolMismatch(
|
|
164
|
+
`skyffla machine protocol ${parsed.major}.${parsed.minor} is incompatible with Node wrapper protocol ${MACHINE_PROTOCOL_VERSION.major}.${MACHINE_PROTOCOL_VERSION.minor}. Wrappers require the same machine protocol major version.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseMember(value, fieldName = "member") {
|
|
170
|
+
const member = requireObject(value, fieldName);
|
|
171
|
+
return Object.freeze(
|
|
172
|
+
copyDefined([
|
|
173
|
+
["member_id", requireString(member.member_id, `${fieldName}.member_id`)],
|
|
174
|
+
["name", requireString(member.name, `${fieldName}.name`)],
|
|
175
|
+
["fingerprint", optionalString(member.fingerprint, `${fieldName}.fingerprint`)],
|
|
176
|
+
]),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseOpenChannelLike(input, fieldName = "message") {
|
|
181
|
+
const blob = input.blob === undefined ? undefined : parseBlobRef(input.blob);
|
|
182
|
+
const kind = requireEnum(input.kind, `${fieldName}.kind`, CHANNEL_KINDS);
|
|
183
|
+
if (kind === ChannelKind.FILE && blob === undefined) {
|
|
184
|
+
throw new SkyfflaProtocolError(`${fieldName}.blob is required for file channels`);
|
|
185
|
+
}
|
|
186
|
+
if (kind !== ChannelKind.FILE && blob !== undefined) {
|
|
187
|
+
throw new SkyfflaProtocolError(`${fieldName}.blob is only allowed for file channels`);
|
|
188
|
+
}
|
|
189
|
+
return Object.freeze(
|
|
190
|
+
copyDefined([
|
|
191
|
+
["channel_id", requireString(input.channel_id, `${fieldName}.channel_id`)],
|
|
192
|
+
["kind", kind],
|
|
193
|
+
["name", optionalString(input.name, `${fieldName}.name`)],
|
|
194
|
+
["size", optionalInteger(input.size, `${fieldName}.size`, { min: 0 })],
|
|
195
|
+
["mime", optionalString(input.mime, `${fieldName}.mime`)],
|
|
196
|
+
["blob", blob],
|
|
197
|
+
]),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parseMachineCommand(value) {
|
|
202
|
+
const payload = typeof value === "string" ? JSON.parse(value) : value;
|
|
203
|
+
const message = requireObject(payload, "command");
|
|
204
|
+
const type = requireString(message.type, "command.type");
|
|
205
|
+
|
|
206
|
+
switch (type) {
|
|
207
|
+
case "send_chat":
|
|
208
|
+
return Object.freeze({
|
|
209
|
+
type,
|
|
210
|
+
to: normalizeRoute(message.to),
|
|
211
|
+
text: requireString(message.text, "command.text"),
|
|
212
|
+
});
|
|
213
|
+
case "send_file":
|
|
214
|
+
return Object.freeze(
|
|
215
|
+
copyDefined([
|
|
216
|
+
["type", type],
|
|
217
|
+
["channel_id", requireString(message.channel_id, "command.channel_id")],
|
|
218
|
+
["to", normalizeRoute(message.to)],
|
|
219
|
+
["path", requireString(message.path, "command.path")],
|
|
220
|
+
["name", optionalString(message.name, "command.name")],
|
|
221
|
+
["mime", optionalString(message.mime, "command.mime")],
|
|
222
|
+
]),
|
|
223
|
+
);
|
|
224
|
+
case "open_channel": {
|
|
225
|
+
const parsed = parseOpenChannelLike(message, "command");
|
|
226
|
+
return Object.freeze({
|
|
227
|
+
type,
|
|
228
|
+
...parsed,
|
|
229
|
+
to: normalizeRoute(message.to),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
case "accept_channel":
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
type,
|
|
235
|
+
channel_id: requireString(message.channel_id, "command.channel_id"),
|
|
236
|
+
});
|
|
237
|
+
case "reject_channel":
|
|
238
|
+
return Object.freeze(
|
|
239
|
+
copyDefined([
|
|
240
|
+
["type", type],
|
|
241
|
+
["channel_id", requireString(message.channel_id, "command.channel_id")],
|
|
242
|
+
["reason", optionalString(message.reason, "command.reason")],
|
|
243
|
+
]),
|
|
244
|
+
);
|
|
245
|
+
case "send_channel_data":
|
|
246
|
+
return Object.freeze({
|
|
247
|
+
type,
|
|
248
|
+
channel_id: requireString(message.channel_id, "command.channel_id"),
|
|
249
|
+
body: requireBody(message.body, "command.body"),
|
|
250
|
+
});
|
|
251
|
+
case "close_channel":
|
|
252
|
+
return Object.freeze(
|
|
253
|
+
copyDefined([
|
|
254
|
+
["type", type],
|
|
255
|
+
["channel_id", requireString(message.channel_id, "command.channel_id")],
|
|
256
|
+
["reason", optionalString(message.reason, "command.reason")],
|
|
257
|
+
]),
|
|
258
|
+
);
|
|
259
|
+
case "export_channel_file":
|
|
260
|
+
return Object.freeze({
|
|
261
|
+
type,
|
|
262
|
+
channel_id: requireString(message.channel_id, "command.channel_id"),
|
|
263
|
+
path: requireString(message.path, "command.path"),
|
|
264
|
+
});
|
|
265
|
+
default:
|
|
266
|
+
throw new SkyfflaProtocolError(`unsupported machine command type: ${type}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function parseMachineEvent(value) {
|
|
271
|
+
const payload = typeof value === "string" ? JSON.parse(value) : value;
|
|
272
|
+
const message = requireObject(payload, "event");
|
|
273
|
+
const type = requireString(message.type, "event.type");
|
|
274
|
+
|
|
275
|
+
switch (type) {
|
|
276
|
+
case "room_welcome":
|
|
277
|
+
return Object.freeze({
|
|
278
|
+
type,
|
|
279
|
+
protocol_version: parseProtocolVersion(message.protocol_version),
|
|
280
|
+
room_id: requireString(message.room_id, "event.room_id"),
|
|
281
|
+
self_member: requireString(message.self_member, "event.self_member"),
|
|
282
|
+
host_member: requireString(message.host_member, "event.host_member"),
|
|
283
|
+
});
|
|
284
|
+
case "member_snapshot": {
|
|
285
|
+
if (!Array.isArray(message.members) || message.members.length === 0) {
|
|
286
|
+
throw new SkyfflaProtocolError("event.members must be a non-empty array");
|
|
287
|
+
}
|
|
288
|
+
return Object.freeze({
|
|
289
|
+
type,
|
|
290
|
+
members: Object.freeze(message.members.map((member) => parseMember(member))),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
case "member_joined":
|
|
294
|
+
return Object.freeze({ type, member: parseMember(message.member) });
|
|
295
|
+
case "member_left":
|
|
296
|
+
return Object.freeze(
|
|
297
|
+
copyDefined([
|
|
298
|
+
["type", type],
|
|
299
|
+
["member_id", requireString(message.member_id, "event.member_id")],
|
|
300
|
+
["reason", optionalString(message.reason, "event.reason")],
|
|
301
|
+
]),
|
|
302
|
+
);
|
|
303
|
+
case "chat":
|
|
304
|
+
return Object.freeze({
|
|
305
|
+
type,
|
|
306
|
+
from: requireString(message.from, "event.from"),
|
|
307
|
+
from_name: requireString(message.from_name, "event.from_name"),
|
|
308
|
+
to: parseRoute(message.to),
|
|
309
|
+
text: requireString(message.text, "event.text"),
|
|
310
|
+
});
|
|
311
|
+
case "channel_opened": {
|
|
312
|
+
const parsed = parseOpenChannelLike(message, "event");
|
|
313
|
+
return Object.freeze({
|
|
314
|
+
type,
|
|
315
|
+
...parsed,
|
|
316
|
+
from: requireString(message.from, "event.from"),
|
|
317
|
+
from_name: requireString(message.from_name, "event.from_name"),
|
|
318
|
+
to: parseRoute(message.to),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
case "channel_accepted":
|
|
322
|
+
return Object.freeze({
|
|
323
|
+
type,
|
|
324
|
+
channel_id: requireString(message.channel_id, "event.channel_id"),
|
|
325
|
+
member_id: requireString(message.member_id, "event.member_id"),
|
|
326
|
+
member_name: requireString(message.member_name, "event.member_name"),
|
|
327
|
+
});
|
|
328
|
+
case "channel_rejected":
|
|
329
|
+
return Object.freeze(
|
|
330
|
+
copyDefined([
|
|
331
|
+
["type", type],
|
|
332
|
+
["channel_id", requireString(message.channel_id, "event.channel_id")],
|
|
333
|
+
["member_id", requireString(message.member_id, "event.member_id")],
|
|
334
|
+
["member_name", requireString(message.member_name, "event.member_name")],
|
|
335
|
+
["reason", optionalString(message.reason, "event.reason")],
|
|
336
|
+
]),
|
|
337
|
+
);
|
|
338
|
+
case "channel_data":
|
|
339
|
+
return Object.freeze({
|
|
340
|
+
type,
|
|
341
|
+
channel_id: requireString(message.channel_id, "event.channel_id"),
|
|
342
|
+
from: requireString(message.from, "event.from"),
|
|
343
|
+
from_name: requireString(message.from_name, "event.from_name"),
|
|
344
|
+
body: requireBody(message.body, "event.body"),
|
|
345
|
+
});
|
|
346
|
+
case "channel_closed":
|
|
347
|
+
return Object.freeze(
|
|
348
|
+
copyDefined([
|
|
349
|
+
["type", type],
|
|
350
|
+
["channel_id", requireString(message.channel_id, "event.channel_id")],
|
|
351
|
+
["member_id", requireString(message.member_id, "event.member_id")],
|
|
352
|
+
["member_name", requireString(message.member_name, "event.member_name")],
|
|
353
|
+
["reason", optionalString(message.reason, "event.reason")],
|
|
354
|
+
]),
|
|
355
|
+
);
|
|
356
|
+
case "channel_file_ready":
|
|
357
|
+
return Object.freeze({
|
|
358
|
+
type,
|
|
359
|
+
channel_id: requireString(message.channel_id, "event.channel_id"),
|
|
360
|
+
blob: parseBlobRef(message.blob),
|
|
361
|
+
});
|
|
362
|
+
case "channel_file_exported":
|
|
363
|
+
return Object.freeze({
|
|
364
|
+
type,
|
|
365
|
+
channel_id: requireString(message.channel_id, "event.channel_id"),
|
|
366
|
+
path: requireString(message.path, "event.path"),
|
|
367
|
+
size: requireInteger(message.size, "event.size", { min: 0 }),
|
|
368
|
+
});
|
|
369
|
+
case "transfer_progress":
|
|
370
|
+
return Object.freeze(
|
|
371
|
+
copyDefined([
|
|
372
|
+
["type", type],
|
|
373
|
+
["channel_id", requireString(message.channel_id, "event.channel_id")],
|
|
374
|
+
["item_kind", requireEnum(message.item_kind, "event.item_kind", TRANSFER_ITEM_KINDS)],
|
|
375
|
+
["name", requireString(message.name, "event.name")],
|
|
376
|
+
["phase", requireEnum(message.phase, "event.phase", TRANSFER_PHASES)],
|
|
377
|
+
["bytes_complete", requireInteger(message.bytes_complete, "event.bytes_complete", { min: 0 })],
|
|
378
|
+
["bytes_total", optionalInteger(message.bytes_total, "event.bytes_total", { min: 0 })],
|
|
379
|
+
]),
|
|
380
|
+
);
|
|
381
|
+
case "error":
|
|
382
|
+
return Object.freeze(
|
|
383
|
+
copyDefined([
|
|
384
|
+
["type", type],
|
|
385
|
+
["code", requireString(message.code, "event.code")],
|
|
386
|
+
["message", requireString(message.message, "event.message")],
|
|
387
|
+
["channel_id", optionalString(message.channel_id, "event.channel_id")],
|
|
388
|
+
]),
|
|
389
|
+
);
|
|
390
|
+
default:
|
|
391
|
+
throw new SkyfflaProtocolError(`unsupported machine event type: ${type}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function dumpMessage(message) {
|
|
396
|
+
const parsed = MACHINE_COMMAND_TYPES.has(message?.type)
|
|
397
|
+
? parseMachineCommand(message)
|
|
398
|
+
: parseMachineEvent(message);
|
|
399
|
+
return JSON.parse(JSON.stringify(parsed));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function dumpMessageJson(message) {
|
|
403
|
+
return JSON.stringify(dumpMessage(message));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function parseCliVersion(text) {
|
|
407
|
+
const match = VERSION_RE.exec(text);
|
|
408
|
+
if (match === null) {
|
|
409
|
+
throw new SkyfflaVersionMismatch(
|
|
410
|
+
`could not parse skyffla version from output: ${JSON.stringify(text)}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
return match[1];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function shouldSkipVersionCheck() {
|
|
417
|
+
const value = process.env.SKYFFLA_SKIP_VERSION_CHECK?.trim().toLowerCase() ?? "";
|
|
418
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function ensureReleaseVersion(binaryVersion) {
|
|
422
|
+
if (binaryVersion !== __version__) {
|
|
423
|
+
throw new SkyfflaVersionMismatch(
|
|
424
|
+
`skyffla Node package ${__version__} requires matching skyffla binary, got ${binaryVersion}. Install the corresponding CLI version or set SKYFFLA_SKIP_VERSION_CHECK=1 to bypass the check.`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|