@neta-art/cohub-cli 10.2.0 → 10.4.1
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/README.md +40 -2
- package/dist/board-export-resources.d.ts +8 -0
- package/dist/board-export-resources.js +39 -0
- package/dist/board-export.d.ts +6 -0
- package/dist/board-export.js +58 -8
- package/dist/board-kernel.d.ts +1 -1
- package/dist/board-kernel.js +211 -141
- package/dist/commands/boards/authoring.js +14 -16
- package/dist/commands/boards.d.ts +1 -0
- package/dist/commands/boards.js +44 -13
- package/dist/commands/rooms-connect.d.ts +17 -0
- package/dist/commands/rooms-connect.js +140 -0
- package/dist/commands/rooms-rtc-binary.d.ts +2 -0
- package/dist/commands/rooms-rtc-binary.js +107 -0
- package/dist/commands/rooms-rtc.d.ts +21 -0
- package/dist/commands/rooms-rtc.js +171 -0
- package/dist/commands/rooms.js +45 -0
- package/dist/commands/sandboxd-binary.d.ts +3 -3
- package/dist/commands/sandboxd-binary.js +27 -17
- package/dist/runtime/harness.d.ts +5 -0
- package/dist/runtime/harness.js +29 -3
- package/dist/runtime/json-rpc.js +1 -1
- package/dist/runtime/launch.js +21 -6
- package/dist/runtime/native/attach.js +5 -2
- package/dist/runtime/native/codex-link.d.ts +2 -0
- package/dist/runtime/native/codex-link.js +3 -1
- package/dist/runtime/native/execution.js +3 -3
- package/dist/runtime/native/ingest.js +4 -4
- package/dist/runtime/native/paths.d.ts +2 -0
- package/dist/runtime/native/paths.js +7 -0
- package/dist/runtime/native/pi-extension.js +12 -2
- package/dist/runtime/native/pi-link.js +21 -4
- package/dist/runtime/native/version.js +4 -1
- package/dist/runtime/supervisor.js +33 -4
- package/package.json +3 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readBoardJsonObject } from "../../board-command-support.js";
|
|
2
2
|
import { loadBoardDocument, resolveBundledFonts } from "../../board-export.js";
|
|
3
|
-
import { boardPathTrack, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createNodeBoardRenderer,
|
|
3
|
+
import { boardPathTrack, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createNodeBoardRenderer, inspectBoardItems, } from "../../board-kernel.js";
|
|
4
4
|
import { parseBoardTimes } from "../../board-time.js";
|
|
5
5
|
import { handleHttp, json as outJson } from "../../output.js";
|
|
6
6
|
import { resolveSpace } from "../../space.js";
|
|
@@ -9,9 +9,14 @@ export function registerBoardAuthoringCommands(boards) {
|
|
|
9
9
|
.command("inspect <board>")
|
|
10
10
|
.description("Measure items and report visible layout hints without changing the Board")
|
|
11
11
|
.option("--animation <id>", "Animation to sample")
|
|
12
|
-
.option("--at <times>", "Time, comma-separated times, or start:end:step", "0")
|
|
12
|
+
.option("--at <times>", "Time, comma-separated times, or start:end:step (up to 100 samples)", "0")
|
|
13
13
|
.option("--items <ids>", "Limit measurements to comma-separated item ids")
|
|
14
14
|
.option("--json", "Output as JSON")
|
|
15
|
+
.addHelpText("after", `
|
|
16
|
+
Without --animation, inspects the static document, not playback.
|
|
17
|
+
Bounds use world units with x/y at the top left; worldBounds includes rotation.
|
|
18
|
+
Checks native layout only, not camera cropping or content drawn inside scripts.
|
|
19
|
+
Example: cohub boards inspect lesson.board --animation lecture --at 0:20s:1s --json`)
|
|
15
20
|
.action(async (target, options) => {
|
|
16
21
|
try {
|
|
17
22
|
const { document } = await loadBoardDocument(await resolveSpace(boards), target);
|
|
@@ -21,20 +26,13 @@ export function registerBoardAuthoringCommands(boards) {
|
|
|
21
26
|
try {
|
|
22
27
|
const ids = options.items ? new Set(options.items.split(",")) : null;
|
|
23
28
|
outJson({
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.map((item) => ({
|
|
32
|
-
id: item.id,
|
|
33
|
-
bounds: item.frame,
|
|
34
|
-
...(item.type === "text" ? { text: measureBoardText(item.props) } : {}),
|
|
35
|
-
})),
|
|
36
|
-
};
|
|
37
|
-
}),
|
|
29
|
+
mode: sampler ? "animation" : "static",
|
|
30
|
+
animation: options.animation ?? null,
|
|
31
|
+
coverage: "Native layout in world coordinates; excludes camera cropping and script-drawn content.",
|
|
32
|
+
samples: times.map((time) => ({
|
|
33
|
+
time,
|
|
34
|
+
...inspectBoardItems(sampler?.at(time).scene ?? buildBoardScene(document), ids ?? undefined),
|
|
35
|
+
})),
|
|
38
36
|
});
|
|
39
37
|
}
|
|
40
38
|
finally {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { parseBoardJsonObject } from "../board-command-support.js";
|
|
3
3
|
export declare const parseJsonObject: typeof parseBoardJsonObject;
|
|
4
|
+
export declare function boardSchemaField(schema: Record<string, unknown> | undefined, path: string): unknown;
|
|
4
5
|
export declare function parseRect(value: string, name?: string): {
|
|
5
6
|
x: number;
|
|
6
7
|
y: number;
|
package/dist/commands/boards.js
CHANGED
|
@@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { mkdir } from "node:fs/promises";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
|
-
import { BOARD_PRESET_NAMES, BOARD_SCHEMA_TARGETS, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
|
|
5
|
+
import { BOARD_PRESET_NAMES, BOARD_SCHEMA_TARGETS, BOARD_SCRIPT_API, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
|
|
6
6
|
import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
|
|
7
|
+
import { BOARD_EXPORT_IMAGE_MEMORY_MIB } from "../board-export-resources.js";
|
|
7
8
|
import { formatBoardTime, parseBoardTime, parseBoardTimes } from "../board-time.js";
|
|
8
9
|
import { createClient, createRealtimeClient } from "../client.js";
|
|
9
10
|
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
@@ -11,6 +12,16 @@ import { resolveSpace } from "../space.js";
|
|
|
11
12
|
import { registerBoardExampleCommands } from "./boards/examples.js";
|
|
12
13
|
import { registerBoardAuthoringCommands } from "./boards/authoring.js";
|
|
13
14
|
export const parseJsonObject = parseBoardJsonObject;
|
|
15
|
+
export function boardSchemaField(schema, path) {
|
|
16
|
+
let field = schema;
|
|
17
|
+
for (const part of path.split(".")) {
|
|
18
|
+
const properties = field?.properties;
|
|
19
|
+
if (!part || !properties || !Object.hasOwn(properties, part))
|
|
20
|
+
throw new Error(`Unknown schema field ${path}.`);
|
|
21
|
+
field = properties[part];
|
|
22
|
+
}
|
|
23
|
+
return field;
|
|
24
|
+
}
|
|
14
25
|
function withJson(command) {
|
|
15
26
|
return command.option("--json", "Output as JSON");
|
|
16
27
|
}
|
|
@@ -152,6 +163,7 @@ function registerExportCommand(boards) {
|
|
|
152
163
|
.option("--format <format>", `Override the format (${BOARD_EXPORT_FORMATS.join(", ")}, ${BOARD_VIDEO_FORMATS.join(", ")})`)
|
|
153
164
|
.option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
|
|
154
165
|
.option("--fps <rate>", "Video frame rate; defaults to the --at step")
|
|
166
|
+
.option("--image-memory <mib>", "Decoded image memory budget in MiB", String(BOARD_EXPORT_IMAGE_MEMORY_MIB))
|
|
155
167
|
.option("--no-images", "Draw placeholders instead of downloading images")
|
|
156
168
|
.option("--force", "Replace existing output files"))
|
|
157
169
|
.addHelpText("after", `
|
|
@@ -180,6 +192,11 @@ Examples:
|
|
|
180
192
|
if (!jsonRequested(options) && (completed === total || completed % 30 === 0))
|
|
181
193
|
process.stderr.write(`Rendered ${completed}/${total} frames\n`);
|
|
182
194
|
};
|
|
195
|
+
const imageMemoryMiB = parseNumber(options.imageMemory ?? String(BOARD_EXPORT_IMAGE_MEMORY_MIB), "--image-memory", { min: 1, max: 65536, integer: true });
|
|
196
|
+
const onPlan = ({ width, height, frames }) => {
|
|
197
|
+
if (!jsonRequested(options))
|
|
198
|
+
process.stderr.write(`Rendering ${width}x${height}, ${frames} frame(s)\n`);
|
|
199
|
+
};
|
|
183
200
|
const pathVideoFormat = videoFormatFromPath(out);
|
|
184
201
|
const requestedFormat = options.format?.toLowerCase();
|
|
185
202
|
if (requestedFormat && ![...BOARD_EXPORT_FORMATS, ...BOARD_VIDEO_FORMATS].includes(requestedFormat))
|
|
@@ -204,6 +221,8 @@ Examples:
|
|
|
204
221
|
target,
|
|
205
222
|
camera,
|
|
206
223
|
onProgress: progress,
|
|
224
|
+
onPlan,
|
|
225
|
+
imageMemoryMiB,
|
|
207
226
|
signal: controller.signal,
|
|
208
227
|
force: options.force,
|
|
209
228
|
region: parseExportRegion(options),
|
|
@@ -238,6 +257,8 @@ Examples:
|
|
|
238
257
|
target,
|
|
239
258
|
camera,
|
|
240
259
|
onProgress: progress,
|
|
260
|
+
onPlan,
|
|
261
|
+
imageMemoryMiB,
|
|
241
262
|
signal: controller.signal,
|
|
242
263
|
onFrame: async (frame, index) => {
|
|
243
264
|
const path = sequence ? framePath(out, index) : out;
|
|
@@ -283,13 +304,18 @@ export function registerBoards(program) {
|
|
|
283
304
|
.description("Read, write, animate and export Boards (by id or .board path)")
|
|
284
305
|
.addHelpText("after", `
|
|
285
306
|
A Board is one JSON document: { board, items, animations }.
|
|
286
|
-
get
|
|
287
|
-
apply
|
|
288
|
-
schema
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
307
|
+
get read it
|
|
308
|
+
apply write it with a JSON Merge Patch; null deletes
|
|
309
|
+
schema the fields of any part and what they mean; also the script API
|
|
310
|
+
examples starter documents and a custom script module
|
|
311
|
+
preset tracks for a preset motion, ready to apply
|
|
312
|
+
sequence join chapter animations into one timeline
|
|
313
|
+
path sample a polyline into a position track
|
|
314
|
+
inspect measure items and report layout hints
|
|
315
|
+
export render an image, a frame sequence or a video
|
|
316
|
+
play shared playback; also pause, resume, next, seek, stop
|
|
317
|
+
watch stream changes and playback
|
|
318
|
+
history list versions, or restore one
|
|
293
319
|
Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
|
|
294
320
|
withJson(boards.command("create <path>")
|
|
295
321
|
.description("Create a Board file, optionally with an initial document")
|
|
@@ -443,18 +469,23 @@ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
|
|
|
443
469
|
});
|
|
444
470
|
boards.command("schema [target]")
|
|
445
471
|
.description("Print JSON Schema for a part of a Board")
|
|
472
|
+
.option("--field <path>", "Print one field, e.g. props.decoration or props.geometry")
|
|
446
473
|
.addHelpText("after", `
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
.action((target) => {
|
|
474
|
+
Without a target: units, item types, colors and every animatable property.
|
|
475
|
+
With a target: its fields and what they mean; script also lists the module API.
|
|
476
|
+
Targets: ${BOARD_SCHEMA_TARGETS.join(", ")}`)
|
|
477
|
+
.action((target, options) => {
|
|
451
478
|
try {
|
|
479
|
+
if (options.field && !target)
|
|
480
|
+
throw new Error("--field needs a schema target, e.g. schema frame --field props.decoration.");
|
|
452
481
|
if (!target)
|
|
453
482
|
return outJson({ ...boardSchemaOverview(), presets: listBoardPresets() });
|
|
454
483
|
const schema = boardJsonSchema(target);
|
|
455
484
|
if (!schema)
|
|
456
485
|
throw new Error(`Unknown target ${target}; expected ${BOARD_SCHEMA_TARGETS.join(", ")}.`);
|
|
457
|
-
|
|
486
|
+
if (options.field)
|
|
487
|
+
return outJson(boardSchemaField(schema.schema, options.field));
|
|
488
|
+
outJson(target === "script" ? { ...schema, api: BOARD_SCRIPT_API } : schema);
|
|
458
489
|
}
|
|
459
490
|
catch (cause) {
|
|
460
491
|
handleHttp(cause);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
type Input = {
|
|
3
|
+
op: "publish" | "send";
|
|
4
|
+
type: string;
|
|
5
|
+
data: unknown;
|
|
6
|
+
} | {
|
|
7
|
+
op: "presence";
|
|
8
|
+
presence: Record<string, unknown> | null;
|
|
9
|
+
};
|
|
10
|
+
export declare function parseRoomInput(line: string): Input;
|
|
11
|
+
export declare function readRoomLines(input: Readable, maxBytes?: number): AsyncGenerator<string>;
|
|
12
|
+
export declare function connectToRoom(roomId: string, options: {
|
|
13
|
+
after?: string;
|
|
14
|
+
input?: boolean;
|
|
15
|
+
json: boolean;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createRealtimeClient } from "../client.js";
|
|
2
|
+
const MAX_LINE_BYTES = 20 * 1024;
|
|
3
|
+
const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024;
|
|
4
|
+
export function parseRoomInput(line) {
|
|
5
|
+
if (Buffer.byteLength(line) > MAX_LINE_BYTES)
|
|
6
|
+
throw new Error("Room input line is too large");
|
|
7
|
+
let value;
|
|
8
|
+
try {
|
|
9
|
+
value = JSON.parse(line);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error("Room input must be JSON");
|
|
13
|
+
}
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
15
|
+
throw new Error("Room input must be an object");
|
|
16
|
+
const input = value;
|
|
17
|
+
if (input.op === "presence" && (input.presence === null || typeof input.presence === "object" && !Array.isArray(input.presence))) {
|
|
18
|
+
return { op: "presence", presence: input.presence };
|
|
19
|
+
}
|
|
20
|
+
if ((input.op === "publish" || input.op === "send") && typeof input.type === "string" && "data" in input) {
|
|
21
|
+
return { op: input.op, type: input.type, data: input.data };
|
|
22
|
+
}
|
|
23
|
+
throw new Error("Use {\"op\":\"publish|send\",\"type\":\"name\",\"data\":...} or {\"op\":\"presence\",\"presence\":...}");
|
|
24
|
+
}
|
|
25
|
+
export async function* readRoomLines(input, maxBytes = MAX_LINE_BYTES) {
|
|
26
|
+
let pending = Buffer.alloc(0);
|
|
27
|
+
for await (const chunk of input) {
|
|
28
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
29
|
+
let start = 0;
|
|
30
|
+
for (;;) {
|
|
31
|
+
const end = bytes.indexOf(10, start);
|
|
32
|
+
const part = bytes.subarray(start, end < 0 ? bytes.length : end);
|
|
33
|
+
if (pending.length + part.length > maxBytes)
|
|
34
|
+
throw new Error("Room input line is too large");
|
|
35
|
+
pending = pending.length ? Buffer.concat([pending, part]) : part;
|
|
36
|
+
if (end < 0)
|
|
37
|
+
break;
|
|
38
|
+
yield pending.toString("utf8").replace(/\r$/, "");
|
|
39
|
+
pending = Buffer.alloc(0);
|
|
40
|
+
start = end + 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (pending.length)
|
|
44
|
+
yield pending.toString("utf8");
|
|
45
|
+
}
|
|
46
|
+
export async function connectToRoom(roomId, options) {
|
|
47
|
+
const after = options.after === undefined ? undefined : Number(options.after);
|
|
48
|
+
if (after !== undefined && (!Number.isSafeInteger(after) || after < 0))
|
|
49
|
+
throw new Error("--after must be a non-negative sequence number");
|
|
50
|
+
const client = createRealtimeClient();
|
|
51
|
+
let room = null;
|
|
52
|
+
let stopped = false;
|
|
53
|
+
let finish;
|
|
54
|
+
const done = new Promise((resolve) => { finish = resolve; });
|
|
55
|
+
const stop = () => {
|
|
56
|
+
if (stopped)
|
|
57
|
+
return;
|
|
58
|
+
stopped = true;
|
|
59
|
+
finish();
|
|
60
|
+
};
|
|
61
|
+
const write = (kind, value) => {
|
|
62
|
+
if (stopped)
|
|
63
|
+
return;
|
|
64
|
+
if (process.stdout.writableLength > MAX_OUTPUT_BUFFER_BYTES) {
|
|
65
|
+
process.stderr.write("Room output is too slow; disconnecting.\n");
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
stop();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
process.stdout.write(options.json ? `${JSON.stringify({ kind, ...value })}\n` : `${kind}: ${JSON.stringify(value)}\n`);
|
|
71
|
+
};
|
|
72
|
+
const onSignal = () => { stop(); void room?.disconnect().catch(() => undefined); void client.disconnect(); };
|
|
73
|
+
process.once("SIGINT", onSignal);
|
|
74
|
+
process.once("SIGTERM", onSignal);
|
|
75
|
+
let inputTask = null;
|
|
76
|
+
try {
|
|
77
|
+
room = await client.rooms.connect(roomId, {
|
|
78
|
+
after,
|
|
79
|
+
onRoom: (active) => {
|
|
80
|
+
room = active;
|
|
81
|
+
if (stopped) {
|
|
82
|
+
void active.disconnect();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
active.subscribeAll((event) => write("event", { event }));
|
|
86
|
+
active.onMembersChanged((members) => write("members", { members }));
|
|
87
|
+
active.onOutOfSync((from, to) => write("gap", { from, to }));
|
|
88
|
+
active.onSendError((failure) => write("error", { message: failure.message }));
|
|
89
|
+
active.onStateChange((state) => {
|
|
90
|
+
write("state", { state });
|
|
91
|
+
if (state === "closed" || state === "expired")
|
|
92
|
+
stop();
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
if (!stopped)
|
|
97
|
+
write("connected", { roomId, participantId: room.participantId, sequence: room.sequence });
|
|
98
|
+
if (!stopped && options.input) {
|
|
99
|
+
const active = room;
|
|
100
|
+
inputTask = (async () => {
|
|
101
|
+
let lineNumber = 0;
|
|
102
|
+
for await (const line of readRoomLines(process.stdin)) {
|
|
103
|
+
if (stopped)
|
|
104
|
+
break;
|
|
105
|
+
lineNumber++;
|
|
106
|
+
if (!line.trim())
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
const input = parseRoomInput(line);
|
|
110
|
+
if (input.op === "presence")
|
|
111
|
+
await active.setPresence(input.presence);
|
|
112
|
+
else if (input.op === "send")
|
|
113
|
+
active.send(input.type, input.data);
|
|
114
|
+
else {
|
|
115
|
+
const result = await active.publish(input.type, input.data);
|
|
116
|
+
write("ack", { line: lineNumber, sequence: result.sequence, eventId: result.eventId });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (cause) {
|
|
120
|
+
write("input_error", { line: lineNumber, message: cause instanceof Error ? cause.message : String(cause) });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
})().catch((cause) => {
|
|
124
|
+
write("input_error", { message: cause instanceof Error ? cause.message : String(cause) });
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
stop();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
await done;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
if (options.input)
|
|
133
|
+
process.stdin.destroy();
|
|
134
|
+
await room?.disconnect().catch(() => undefined);
|
|
135
|
+
await client.disconnect();
|
|
136
|
+
await inputTask?.catch(() => undefined);
|
|
137
|
+
process.removeListener("SIGINT", onSignal);
|
|
138
|
+
process.removeListener("SIGTERM", onSignal);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { Readable, Transform } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { createGunzip } from "node:zlib";
|
|
9
|
+
// Pin a global release tag after its helper artifacts are published.
|
|
10
|
+
export const RTC_HELPER_VERSION = "v2.67.0";
|
|
11
|
+
const MAX_BINARY_BYTES = 128 * 1024 * 1024;
|
|
12
|
+
async function digest(path) {
|
|
13
|
+
const hash = createHash("sha256");
|
|
14
|
+
await pipeline(createReadStream(path), hash);
|
|
15
|
+
return hash.digest("hex");
|
|
16
|
+
}
|
|
17
|
+
async function readText(response, limit) {
|
|
18
|
+
if (!response.body)
|
|
19
|
+
throw new Error("Empty RTC helper response");
|
|
20
|
+
const reader = response.body.getReader();
|
|
21
|
+
const parts = [];
|
|
22
|
+
let bytes = 0;
|
|
23
|
+
try {
|
|
24
|
+
for (;;) {
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
if (done)
|
|
27
|
+
break;
|
|
28
|
+
bytes += value.length;
|
|
29
|
+
if (bytes > limit)
|
|
30
|
+
throw new Error("RTC helper metadata exceeds the size limit");
|
|
31
|
+
parts.push(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await reader.cancel();
|
|
36
|
+
}
|
|
37
|
+
return Buffer.concat(parts).toString("utf8");
|
|
38
|
+
}
|
|
39
|
+
export async function ensureRtcBinary(local) {
|
|
40
|
+
const override = local || process.env.COHUB_RTC_BIN?.trim();
|
|
41
|
+
if (override) {
|
|
42
|
+
if (!(await lstat(override)).isFile())
|
|
43
|
+
throw new Error("RTC helper must be a regular file");
|
|
44
|
+
return resolve(override);
|
|
45
|
+
}
|
|
46
|
+
const platform = { linux: "linux", darwin: "darwin" }[process.platform];
|
|
47
|
+
const arch = { x64: "amd64", arm64: "arm64" }[process.arch];
|
|
48
|
+
if (!platform || !arch)
|
|
49
|
+
throw new Error("Unsupported RTC helper platform; use --helper with a local binary");
|
|
50
|
+
const version = process.env.COHUB_RTC_VERSION?.trim() || RTC_HELPER_VERSION;
|
|
51
|
+
if (!version)
|
|
52
|
+
throw new Error("No RTC helper release is pinned yet; use --helper or COHUB_RTC_VERSION with a published global release");
|
|
53
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(version))
|
|
54
|
+
throw new Error("Invalid RTC helper version");
|
|
55
|
+
const directory = join(homedir(), ".cache", "cohub", "rtc", version, `${platform}-${arch}`);
|
|
56
|
+
const binary = join(directory, "cohub-rtc");
|
|
57
|
+
const checksum = `${binary}.sha256`;
|
|
58
|
+
const expected = await readFile(checksum, "utf8").catch(() => "");
|
|
59
|
+
if (/^[a-f0-9]{64}$/.test(expected) && (await lstat(binary).catch(() => null))?.isFile() && await digest(binary) === expected)
|
|
60
|
+
return binary;
|
|
61
|
+
await mkdir(directory, { recursive: true });
|
|
62
|
+
const temporary = await mkdtemp(join(directory, ".download-"));
|
|
63
|
+
const base = (process.env.COHUB_RTC_CDN_BASE_URL?.trim() || "https://public.cohub.live/rtc").replace(/\/+$/, "");
|
|
64
|
+
const url = `${base}/${version}/cohub-rtc_${platform}_${arch}.gz`;
|
|
65
|
+
const signal = AbortSignal.timeout(120_000);
|
|
66
|
+
try {
|
|
67
|
+
const manifest = await fetch(`${url}.sha256`, { signal });
|
|
68
|
+
if (!manifest.ok)
|
|
69
|
+
throw new Error(`RTC helper ${version} is unavailable (${manifest.status}); use --helper with a locally built cohub-rtc`);
|
|
70
|
+
const text = await readText(manifest, 1024);
|
|
71
|
+
const expectedArchive = text.trim().split(/\s+/)[0];
|
|
72
|
+
if (!expectedArchive || !/^[a-f0-9]{64}$/.test(expectedArchive))
|
|
73
|
+
throw new Error("Invalid RTC helper checksum");
|
|
74
|
+
const response = await fetch(url, { signal });
|
|
75
|
+
if (!response.ok || !response.body)
|
|
76
|
+
throw new Error(`RTC helper download failed (${response.status})`);
|
|
77
|
+
let bytes = 0;
|
|
78
|
+
const bound = () => new Transform({ transform(chunk, _encoding, done) {
|
|
79
|
+
bytes += chunk.length;
|
|
80
|
+
done(bytes > MAX_BINARY_BYTES ? new Error("RTC helper exceeds the size limit") : null, chunk);
|
|
81
|
+
} });
|
|
82
|
+
const archive = join(temporary, "archive.gz");
|
|
83
|
+
await pipeline(Readable.fromWeb(response.body), bound(), createWriteStream(archive, { flags: "wx" }), { signal });
|
|
84
|
+
if (await digest(archive) !== expectedArchive)
|
|
85
|
+
throw new Error("RTC helper checksum mismatch");
|
|
86
|
+
for (const name of ["LICENSE", "NOTICE"]) {
|
|
87
|
+
const response = await fetch(`${base}/${version}/${name}`, { signal });
|
|
88
|
+
if (!response.ok)
|
|
89
|
+
throw new Error(`RTC helper ${name} is unavailable (${response.status})`);
|
|
90
|
+
const text = await readText(response, 64 * 1024);
|
|
91
|
+
await writeFile(join(temporary, name), text, { flag: "wx" });
|
|
92
|
+
await rename(join(temporary, name), join(directory, name));
|
|
93
|
+
}
|
|
94
|
+
const extracted = join(temporary, "cohub-rtc");
|
|
95
|
+
bytes = 0;
|
|
96
|
+
await pipeline(createReadStream(archive), createGunzip(), bound(), createWriteStream(extracted, { flags: "wx", mode: 0o700 }), { signal });
|
|
97
|
+
await chmod(extracted, 0o700);
|
|
98
|
+
const hash = await digest(extracted);
|
|
99
|
+
await writeFile(join(temporary, "checksum"), hash, { flag: "wx" });
|
|
100
|
+
await rename(extracted, binary);
|
|
101
|
+
await rename(join(temporary, "checksum"), checksum);
|
|
102
|
+
return binary;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await rm(temporary, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type RtcOptions = {
|
|
2
|
+
helper?: string;
|
|
3
|
+
audio?: string;
|
|
4
|
+
video?: string;
|
|
5
|
+
audioFormat: "pcm" | "opus";
|
|
6
|
+
videoFormat: "i420" | "vp8";
|
|
7
|
+
size?: string;
|
|
8
|
+
fps: string;
|
|
9
|
+
rate: string;
|
|
10
|
+
channels: string;
|
|
11
|
+
output?: string;
|
|
12
|
+
receiveFormat: "rtp" | "raw";
|
|
13
|
+
receiveSize: string;
|
|
14
|
+
maxBytes: string;
|
|
15
|
+
participant?: string;
|
|
16
|
+
channel?: string;
|
|
17
|
+
delivery: "reliable" | "latest";
|
|
18
|
+
dataInput?: string;
|
|
19
|
+
json?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare function runRoomRtc(roomId: string, options: RtcOptions): Promise<void>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { HttpError } from "@neta-art/cohub";
|
|
3
|
+
import { createRealtimeClient } from "../client.js";
|
|
4
|
+
import { readRoomLines } from "./rooms-connect.js";
|
|
5
|
+
import { ensureRtcBinary } from "./rooms-rtc-binary.js";
|
|
6
|
+
export async function runRoomRtc(roomId, options) {
|
|
7
|
+
if (options.delivery !== "reliable" && options.delivery !== "latest")
|
|
8
|
+
throw new Error("--delivery must be reliable or latest");
|
|
9
|
+
if (options.dataInput && !options.channel)
|
|
10
|
+
throw new Error("--data-input requires --channel");
|
|
11
|
+
if ([options.audio, options.video, options.dataInput].filter((value) => value === "-").length > 1)
|
|
12
|
+
throw new Error("Only one input can use stdin");
|
|
13
|
+
const maxBytes = Number(options.maxBytes);
|
|
14
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
15
|
+
throw new Error("--max-bytes must be a non-negative safe integer");
|
|
16
|
+
const client = createRealtimeClient();
|
|
17
|
+
const sessions = new Set();
|
|
18
|
+
const pending = new Set();
|
|
19
|
+
let room;
|
|
20
|
+
let helper;
|
|
21
|
+
let control;
|
|
22
|
+
let exit;
|
|
23
|
+
let killTimer;
|
|
24
|
+
let stopped = false;
|
|
25
|
+
let failure;
|
|
26
|
+
const stop = () => {
|
|
27
|
+
if (stopped)
|
|
28
|
+
return;
|
|
29
|
+
stopped = true;
|
|
30
|
+
helper?.kill("SIGTERM");
|
|
31
|
+
if (helper)
|
|
32
|
+
killTimer = setTimeout(() => helper?.kill("SIGKILL"), 5_000);
|
|
33
|
+
};
|
|
34
|
+
const fail = (cause) => { failure ??= cause; stop(); };
|
|
35
|
+
const send = (value) => {
|
|
36
|
+
if (!control || control.destroyed || stopped)
|
|
37
|
+
return;
|
|
38
|
+
const line = `${JSON.stringify(value)}\n`;
|
|
39
|
+
if (Buffer.byteLength(line) > 256 * 1024 || control.writableLength > 1024 * 1024)
|
|
40
|
+
throw new Error("RTC control input is too slow or too large");
|
|
41
|
+
control.write(line);
|
|
42
|
+
};
|
|
43
|
+
let previousMembers = "";
|
|
44
|
+
const report = (value) => {
|
|
45
|
+
const output = options.output === "-" ? process.stderr : process.stdout;
|
|
46
|
+
if (output.writableLength > 1024 * 1024)
|
|
47
|
+
throw new Error("RTC output is too slow");
|
|
48
|
+
output.write(`${JSON.stringify(value)}\n`);
|
|
49
|
+
};
|
|
50
|
+
const release = async (sessionId) => {
|
|
51
|
+
if (!sessions.has(sessionId))
|
|
52
|
+
return;
|
|
53
|
+
await client.rooms.signalRtc(roomId, { method: "DELETE", path: `/${sessionId}` });
|
|
54
|
+
sessions.delete(sessionId);
|
|
55
|
+
};
|
|
56
|
+
const request = async (message) => {
|
|
57
|
+
try {
|
|
58
|
+
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(message.method))
|
|
59
|
+
throw new Error("Invalid RTC signaling method");
|
|
60
|
+
const opening = message.method === "POST" && message.path === "";
|
|
61
|
+
const body = await client.rooms.signalRtc(roomId, {
|
|
62
|
+
method: message.method,
|
|
63
|
+
path: message.path,
|
|
64
|
+
body: opening ? { ticket: "hosted", participantId: room?.participantId } : message.method === "GET" ? undefined : message.body,
|
|
65
|
+
});
|
|
66
|
+
if (opening && body.sessionId)
|
|
67
|
+
sessions.add(body.sessionId);
|
|
68
|
+
if (message.method === "GET" && room) {
|
|
69
|
+
const snapshot = await client.rooms.get(roomId);
|
|
70
|
+
send({ kind: "members", body: snapshot.members.map(({ participantId, publications }) => ({ participantId, publications })) });
|
|
71
|
+
previousMembers = "";
|
|
72
|
+
}
|
|
73
|
+
send({ kind: "response", id: message.id, body });
|
|
74
|
+
}
|
|
75
|
+
catch (cause) {
|
|
76
|
+
send({ kind: "response", id: message.id, error: cause instanceof Error ? cause.message : String(cause), code: cause instanceof HttpError ? cause.code : "RTC_FAILED" });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const track = (task) => {
|
|
80
|
+
pending.add(task);
|
|
81
|
+
void task.catch(fail).finally(() => pending.delete(task));
|
|
82
|
+
};
|
|
83
|
+
process.once("SIGINT", stop);
|
|
84
|
+
process.once("SIGTERM", stop);
|
|
85
|
+
process.stdout.on("error", fail);
|
|
86
|
+
process.stderr.on("error", fail);
|
|
87
|
+
try {
|
|
88
|
+
const binary = await ensureRtcBinary(options.helper);
|
|
89
|
+
if (stopped)
|
|
90
|
+
return;
|
|
91
|
+
room = await client.rooms.connect(roomId);
|
|
92
|
+
if (stopped)
|
|
93
|
+
return;
|
|
94
|
+
room.onStateChange((state) => {
|
|
95
|
+
try {
|
|
96
|
+
report({ kind: "room", state });
|
|
97
|
+
}
|
|
98
|
+
catch (cause) {
|
|
99
|
+
fail(cause);
|
|
100
|
+
}
|
|
101
|
+
if (state === "closed" || state === "expired")
|
|
102
|
+
stop();
|
|
103
|
+
});
|
|
104
|
+
helper = spawn(binary, [], { stdio: ["inherit", "inherit", "inherit", "pipe", "pipe"] });
|
|
105
|
+
control = helper.stdio[3];
|
|
106
|
+
const events = helper.stdio[4];
|
|
107
|
+
control.on("error", fail);
|
|
108
|
+
exit = new Promise((resolve, reject) => {
|
|
109
|
+
helper?.once("error", reject);
|
|
110
|
+
helper?.once("close", (code, signal) => {
|
|
111
|
+
if (killTimer)
|
|
112
|
+
clearTimeout(killTimer);
|
|
113
|
+
if (code === 0 || stopped && signal === "SIGTERM")
|
|
114
|
+
resolve();
|
|
115
|
+
else
|
|
116
|
+
reject(new Error(`RTC helper exited with ${signal ?? code}`));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
void exit.catch(fail);
|
|
120
|
+
send({ protocol: 1, ...options, maxBytes, participantId: room.participantId });
|
|
121
|
+
const updateMembers = () => {
|
|
122
|
+
const body = room?.members.map(({ participantId, publications }) => ({ participantId, publications }));
|
|
123
|
+
const next = JSON.stringify(body);
|
|
124
|
+
if (next === previousMembers)
|
|
125
|
+
return;
|
|
126
|
+
send({ kind: "members", body });
|
|
127
|
+
previousMembers = next;
|
|
128
|
+
};
|
|
129
|
+
room.onMembersChanged(() => { try {
|
|
130
|
+
updateMembers();
|
|
131
|
+
}
|
|
132
|
+
catch (cause) {
|
|
133
|
+
fail(cause);
|
|
134
|
+
} });
|
|
135
|
+
updateMembers();
|
|
136
|
+
report({ kind: "connected", roomId, participantId: room.participantId });
|
|
137
|
+
for await (const line of readRoomLines(events, 256 * 1024)) {
|
|
138
|
+
const message = JSON.parse(line);
|
|
139
|
+
if (message.kind === "request") {
|
|
140
|
+
if (pending.size >= 16)
|
|
141
|
+
throw new Error("Too many pending RTC requests");
|
|
142
|
+
track(request(message));
|
|
143
|
+
}
|
|
144
|
+
else if (message.kind === "release") {
|
|
145
|
+
await release(message.sessionId);
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
report(message);
|
|
149
|
+
}
|
|
150
|
+
await exit;
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
stop();
|
|
154
|
+
await exit?.catch((cause) => { failure ??= cause; });
|
|
155
|
+
if (killTimer)
|
|
156
|
+
clearTimeout(killTimer);
|
|
157
|
+
await Promise.allSettled(pending);
|
|
158
|
+
const results = await Promise.allSettled([...sessions].map(release));
|
|
159
|
+
for (const result of results)
|
|
160
|
+
if (result.status === "rejected")
|
|
161
|
+
failure ??= result.reason;
|
|
162
|
+
await room?.disconnect().catch((cause) => { failure ??= cause; });
|
|
163
|
+
await client.disconnect().catch((cause) => { failure ??= cause; });
|
|
164
|
+
process.removeListener("SIGINT", stop);
|
|
165
|
+
process.removeListener("SIGTERM", stop);
|
|
166
|
+
process.stdout.removeListener("error", fail);
|
|
167
|
+
process.stderr.removeListener("error", fail);
|
|
168
|
+
}
|
|
169
|
+
if (failure)
|
|
170
|
+
throw failure;
|
|
171
|
+
}
|