@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.
Files changed (35) hide show
  1. package/README.md +40 -2
  2. package/dist/board-export-resources.d.ts +8 -0
  3. package/dist/board-export-resources.js +39 -0
  4. package/dist/board-export.d.ts +6 -0
  5. package/dist/board-export.js +58 -8
  6. package/dist/board-kernel.d.ts +1 -1
  7. package/dist/board-kernel.js +211 -141
  8. package/dist/commands/boards/authoring.js +14 -16
  9. package/dist/commands/boards.d.ts +1 -0
  10. package/dist/commands/boards.js +44 -13
  11. package/dist/commands/rooms-connect.d.ts +17 -0
  12. package/dist/commands/rooms-connect.js +140 -0
  13. package/dist/commands/rooms-rtc-binary.d.ts +2 -0
  14. package/dist/commands/rooms-rtc-binary.js +107 -0
  15. package/dist/commands/rooms-rtc.d.ts +21 -0
  16. package/dist/commands/rooms-rtc.js +171 -0
  17. package/dist/commands/rooms.js +45 -0
  18. package/dist/commands/sandboxd-binary.d.ts +3 -3
  19. package/dist/commands/sandboxd-binary.js +27 -17
  20. package/dist/runtime/harness.d.ts +5 -0
  21. package/dist/runtime/harness.js +29 -3
  22. package/dist/runtime/json-rpc.js +1 -1
  23. package/dist/runtime/launch.js +21 -6
  24. package/dist/runtime/native/attach.js +5 -2
  25. package/dist/runtime/native/codex-link.d.ts +2 -0
  26. package/dist/runtime/native/codex-link.js +3 -1
  27. package/dist/runtime/native/execution.js +3 -3
  28. package/dist/runtime/native/ingest.js +4 -4
  29. package/dist/runtime/native/paths.d.ts +2 -0
  30. package/dist/runtime/native/paths.js +7 -0
  31. package/dist/runtime/native/pi-extension.js +12 -2
  32. package/dist/runtime/native/pi-link.js +21 -4
  33. package/dist/runtime/native/version.js +4 -1
  34. package/dist/runtime/supervisor.js +33 -4
  35. 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, inspectBoardScene, measureBoardText, } from "../../board-kernel.js";
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
- samples: times.map((time) => {
25
- const scene = sampler?.at(time).scene ?? buildBoardScene(document);
26
- return {
27
- time,
28
- diagnostics: inspectBoardScene(scene),
29
- items: scene.items
30
- .filter((item) => !ids || ids.has(item.id))
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;
@@ -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 read it
287
- apply write it with a JSON Merge Patch; null deletes
288
- schema the schema of any part, and what tracks can animate
289
- preset tracks for a preset motion, ready to apply
290
- history list versions, or restore one
291
- export render an image, a frame sequence or a video
292
- examples starter documents
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
- With a target: one item type, or animation, track, or camera.
448
- Targets: ${BOARD_SCHEMA_TARGETS.join(", ")}
449
- Without a target: units, item types, colors and every animatable property.`)
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
- outJson(schema);
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,2 @@
1
+ export declare const RTC_HELPER_VERSION: string;
2
+ export declare function ensureRtcBinary(local?: string): Promise<string>;
@@ -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
+ }