@neta-art/cohub-cli 3.11.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ /** Connection shortcuts; Item authoring lives in ./items.ts. */
3
+ export declare function registerBoardNodeCommands(boards: Command): void;
@@ -0,0 +1,53 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createBoardConnection } from "@neta-art/cohub/board";
3
+ import { handleHttp } from "../../output.js";
4
+ import { resolvedBoard, showUpdated, withJson, } from "./context.js";
5
+ /** Connection shortcuts; Item authoring lives in ./items.ts. */
6
+ export function registerBoardNodeCommands(boards) {
7
+ withJson(boards.command("connect <board> <source> <target>")
8
+ .description("Connect two Board items")
9
+ .option("--id <id>", "Connection id")
10
+ .option("--relation <relation>", "Relation type")
11
+ .option("--direction <direction>", "none, forward, backward, or both", "forward")
12
+ .option("--label <label>", "Connection label")
13
+ .option("--source-port <id>", "Source port id")
14
+ .option("--target-port <id>", "Target port id"))
15
+ .action(async (target, source, destination, options) => {
16
+ try {
17
+ const direction = options.direction ?? "forward";
18
+ if (!["none", "forward", "backward", "both"].includes(direction)) {
19
+ throw new Error("--direction must be none, forward, backward, or both");
20
+ }
21
+ const board = await resolvedBoard(boards, target);
22
+ const connection = createBoardConnection({
23
+ id: options.id ?? randomUUID(),
24
+ sourceNodeId: source,
25
+ targetNodeId: destination,
26
+ relation: options.relation,
27
+ direction: direction,
28
+ label: options.label,
29
+ sourcePortId: options.sourcePort,
30
+ targetPortId: options.targetPort,
31
+ });
32
+ showUpdated(await board.mutate({
33
+ build: () => [{ type: "connection.create", payload: { connection } }],
34
+ }), options);
35
+ }
36
+ catch (cause) {
37
+ handleHttp(cause);
38
+ }
39
+ });
40
+ withJson(boards.command("disconnect <board> <connection-id>")
41
+ .description("Remove a Board connection"))
42
+ .action(async (target, connectionId, options) => {
43
+ try {
44
+ const board = await resolvedBoard(boards, target);
45
+ showUpdated(await board.mutate({
46
+ build: () => [{ type: "connection.delete", payload: { connectionId } }],
47
+ }), options);
48
+ }
49
+ catch (cause) {
50
+ handleHttp(cause);
51
+ }
52
+ });
53
+ }
@@ -1,9 +1,10 @@
1
1
  import type { BoardInspectInput, BoardTransactionInput } from "@neta-art/cohub";
2
2
  import type { Command } from "commander";
3
- declare const INSPECT_SECTIONS: readonly ["nodes", "connections", "effects", "sequences", "clips", "playback"];
3
+ import { parseBoardJsonObject } from "../board-command-support.js";
4
+ declare const INSPECT_SECTIONS: readonly ["nodes", "connections", "effects", "compositions", "playback"];
4
5
  type InspectSection = (typeof INSPECT_SECTIONS)[number];
5
- export declare function parseJsonObject(text: string, source?: string): Record<string, unknown>;
6
- export declare function readJsonObject(source: string): Promise<Record<string, unknown>>;
6
+ export declare const parseJsonObject: typeof parseBoardJsonObject;
7
+ export declare function readJsonObject(source: string, maxBytes?: number): Promise<Record<string, unknown>>;
7
8
  export declare function parseInspectSections(value?: string): InspectSection[] | undefined;
8
9
  export declare function parseViewport(value?: string): BoardInspectInput["viewport"];
9
10
  export declare function createTransactionInput(input: Record<string, unknown>, options: {
@@ -1,36 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFile, writeFile } from "node:fs/promises";
2
+ import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
3
3
  import { BOARD_EXPORT_FORMATS, formatFromPath, runBoardExport } from "../board-export.js";
4
+ import { registerBoardDomainCommands } from "./board-domain.js";
4
5
  import { createClient, createRealtimeClient } from "../client.js";
5
6
  import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
6
7
  import { resolveSpace } from "../space.js";
7
- const INSPECT_SECTIONS = ["nodes", "connections", "effects", "sequences", "clips", "playback"];
8
- function isObject(value) {
9
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
10
- }
11
- export function parseJsonObject(text, source = "input") {
12
- if (!text.trim())
13
- throw new Error(`${source} is empty`);
14
- let value;
15
- try {
16
- value = JSON.parse(text);
17
- }
18
- catch (cause) {
19
- throw new Error(`${source} must contain valid JSON`, { cause });
20
- }
21
- if (!isObject(value))
22
- throw new Error(`${source} must contain a JSON object`);
23
- return value;
24
- }
25
- async function readStdin() {
26
- const chunks = [];
27
- for await (const chunk of process.stdin)
28
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
29
- return Buffer.concat(chunks).toString("utf8");
30
- }
31
- export async function readJsonObject(source) {
32
- const text = source === "-" ? await readStdin() : await readFile(source, "utf8");
33
- return parseJsonObject(text, source === "-" ? "stdin" : source);
8
+ const INSPECT_SECTIONS = ["nodes", "connections", "effects", "compositions", "playback"];
9
+ export const parseJsonObject = parseBoardJsonObject;
10
+ export async function readJsonObject(source, maxBytes = BOARD_TRANSACTION_INPUT_MAX_BYTES) {
11
+ return readBoardJsonObject(source, maxBytes);
34
12
  }
35
13
  function parseNumber(value, name, options = {}) {
36
14
  if (!value.trim())
@@ -102,8 +80,7 @@ function showBoard(result) {
102
80
  nodes: result.nodes.length,
103
81
  connections: result.connections.length,
104
82
  effects: result.effects.length,
105
- sequences: result.sequences.length,
106
- clips: result.clips.length,
83
+ compositions: result.compositions.length,
107
84
  },
108
85
  ], [
109
86
  { key: "id", label: "ID" },
@@ -112,8 +89,28 @@ function showBoard(result) {
112
89
  { key: "nodes", label: "Nodes" },
113
90
  { key: "connections", label: "Connections" },
114
91
  { key: "effects", label: "Effects" },
115
- { key: "sequences", label: "Sequences" },
116
- { key: "clips", label: "Clips" },
92
+ { key: "compositions", label: "Compositions" },
93
+ ]);
94
+ }
95
+ function showSummary(result) {
96
+ const background = result.board.metadata.appearance;
97
+ table([{
98
+ id: result.board.id,
99
+ title: result.board.title,
100
+ version: result.board.version,
101
+ ...result.counts,
102
+ background: background?.background?.kind ?? "default",
103
+ updatedAt: result.board.updatedAt,
104
+ }], [
105
+ { key: "id", label: "ID" },
106
+ { key: "title", label: "TITLE" },
107
+ { key: "version", label: "VERSION" },
108
+ { key: "nodes", label: "NODES" },
109
+ { key: "connections", label: "CONNECTIONS" },
110
+ { key: "effects", label: "EFFECTS" },
111
+ { key: "compositions", label: "COMPOSITIONS" },
112
+ { key: "background", label: "BACKGROUND" },
113
+ { key: "updatedAt", label: "UPDATED" },
117
114
  ]);
118
115
  }
119
116
  function showValidation(result) {
@@ -136,7 +133,7 @@ function showValidation(result) {
136
133
  function showPlayback(result) {
137
134
  table([result], [
138
135
  { key: "playbackId", label: "Playback ID" },
139
- { key: "sequenceId", label: "Sequence" },
136
+ { key: "compositionId", label: "Composition" },
140
137
  { key: "status", label: "Status" },
141
138
  { key: "position", label: "Position" },
142
139
  { key: "timeScale", label: "Time Scale" },
@@ -145,24 +142,45 @@ function showPlayback(result) {
145
142
  function withJson(command) {
146
143
  return command.option("--json", "Output as JSON");
147
144
  }
145
+ function capabilityUnits(schema) {
146
+ const params = schema?.params;
147
+ if (!params || typeof params !== "object" || Array.isArray(params))
148
+ return "";
149
+ return Object.entries(params).flatMap(([field, value]) => {
150
+ if (!value || typeof value !== "object" || Array.isArray(value))
151
+ return [];
152
+ const meta = value;
153
+ const detail = [meta.coordinateSpace, meta.unit]
154
+ .filter((item) => typeof item === "string")
155
+ .join("/");
156
+ return detail ? [`${field}:${detail}`] : [];
157
+ }).join(", ");
158
+ }
148
159
  function registerTransactionCommand(boards, name) {
149
- withJson(boards.command(`${name} <board-id>`)
160
+ withJson(boards.command(`${name} <board>`)
150
161
  .description(name === "validate" ? "Validate a transaction" : "Apply a transaction")
151
162
  .requiredOption("-i, --input <file>", "Transaction JSON file; use - for stdin")
152
163
  .option("--tx-id <id>", "Override txId; generated when omitted")
153
- .option("--base-version <version>", "Override baseVersion"))
154
- .action(async (boardId, options) => {
164
+ .option("--base-version <version>", "Override baseVersion")
165
+ .addHelpText("after", `
166
+ Input example:
167
+ {"baseVersion":12,"operations":[{"type":"board.patch","payload":{"patch":{"title":"Launch plan"}}}]}
168
+
169
+ Prefer semantic commands such as boards background, nodes, effects, or compositions for common edits.`))
170
+ .action(async (target, options) => {
155
171
  try {
156
- const transaction = createTransactionInput(await readJsonObject(options.input), options);
157
- const board = createClient().space(resolveSpace(boards)).board(boardId);
172
+ const transaction = createTransactionInput(await readJsonObject(options.input, BOARD_TRANSACTION_INPUT_MAX_BYTES), options);
173
+ const spaceId = resolveSpace(boards);
174
+ const boardId = await resolveBoardId(spaceId, target);
175
+ const board = createClient().space(spaceId).board(boardId);
158
176
  const result = await board[name](transaction);
159
177
  if (jsonRequested(options))
160
178
  return outJson(result);
161
179
  if (name === "validate")
162
180
  showValidation(result);
163
181
  else {
164
- ok(`Board updated to version ${result.board.version}`);
165
- showBoard(result);
182
+ const receipt = result;
183
+ ok(`Board updated to version ${receipt.board.version}`);
166
184
  }
167
185
  }
168
186
  catch (cause) {
@@ -240,7 +258,8 @@ function registerExportCommand(boards) {
240
258
  .option("--background <mode>", "paper or transparent", "paper")
241
259
  .option("--format <format>", `Override format (${BOARD_EXPORT_FORMATS.join(", ")})`)
242
260
  .option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
243
- .option("--no-images", "Skip image downloads and draw placeholders"))
261
+ .option("--no-images", "Skip image downloads and draw placeholders")
262
+ .option("--force", "Replace an existing output file"))
244
263
  .action(async (board, options) => {
245
264
  try {
246
265
  const out = options.out;
@@ -263,7 +282,7 @@ function registerExportCommand(boards) {
263
282
  if (!result) {
264
283
  return error("Nothing to export", "The selected region contains no items.");
265
284
  }
266
- await writeFile(out, result.bytes);
285
+ await writeBoardOutput(out, result.bytes, Boolean(options.force));
267
286
  if (jsonRequested(options)) {
268
287
  return outJson({
269
288
  path: out,
@@ -293,14 +312,26 @@ export function registerBoards(program) {
293
312
  withJson(boards.command("create <path>")
294
313
  .description("Create a Board")
295
314
  .option("--title <title>", "Board title")
296
- .option("-i, --input <file>", "Board content JSON file; use - for stdin"))
315
+ .option("--mutation-id <id>", "Stable id for safe retries")
316
+ .option("-i, --input <file>", "BoardCreateInput fields; use - for stdin")
317
+ .addHelpText("after", `
318
+ For normal use, create an empty Board and add content with boards nodes, effects, and compositions.
319
+ --input is intended for bulk creation and accepts BoardCreateInput fields except path and title.`))
297
320
  .action(async (path, options) => {
298
321
  try {
299
- const content = options.input ? await readJsonObject(options.input) : {};
322
+ const content = options.input
323
+ ? await readJsonObject(options.input, BOARD_CREATE_INPUT_MAX_BYTES)
324
+ : {};
300
325
  if ("path" in content || "title" in content) {
301
326
  throw new Error("create input must not contain path or title; use the command argument and --title");
302
327
  }
303
- const input = { ...content, path, ...(options.title ? { title: options.title } : {}) };
328
+ const input = {
329
+ ...content,
330
+ path,
331
+ mutationId: options.mutationId ??
332
+ (typeof content.mutationId === "string" ? content.mutationId : randomUUID()),
333
+ ...(options.title ? { title: options.title } : {}),
334
+ };
304
335
  const result = await createClient().space(resolveSpace(boards)).boards.create(input);
305
336
  if (jsonRequested(options))
306
337
  return outJson(result);
@@ -311,14 +342,20 @@ export function registerBoards(program) {
311
342
  handleHttp(cause);
312
343
  }
313
344
  });
314
- withJson(boards.command("inspect <board-id>")
345
+ withJson(boards.command("inspect <board>")
315
346
  .alias("get")
316
347
  .description("Inspect a Board")
317
- .option("--include <sections>", "Comma-separated nodes,connections,effects,sequences,clips,playback")
348
+ .option("--include <sections>", "Comma-separated nodes,connections,effects,compositions,playback")
318
349
  .option("--viewport <rect>", "Viewport as x,y,width,height"))
319
- .action(async (boardId, options) => {
350
+ .action(async (target, options) => {
320
351
  try {
321
- const result = await createClient().space(resolveSpace(boards)).board(boardId).inspect({
352
+ const spaceId = resolveSpace(boards);
353
+ const boardId = await resolveBoardId(spaceId, target);
354
+ const board = createClient().space(spaceId).board(boardId);
355
+ if (!jsonRequested(options) && !options.include && !options.viewport) {
356
+ return showSummary(await board.summary());
357
+ }
358
+ const result = await board.inspect({
322
359
  include: parseInspectSections(options.include),
323
360
  viewport: parseViewport(options.viewport),
324
361
  });
@@ -330,21 +367,25 @@ export function registerBoards(program) {
330
367
  handleHttp(cause);
331
368
  }
332
369
  });
333
- withJson(boards.command("capabilities <board-id>")
370
+ withJson(boards.command("capabilities <board>")
334
371
  .description("Show supported capabilities"))
335
- .action(async (boardId, options) => {
372
+ .action(async (target, options) => {
336
373
  try {
337
- const result = await createClient().space(resolveSpace(boards)).board(boardId).capabilities();
374
+ const spaceId = resolveSpace(boards);
375
+ const boardId = await resolveBoardId(spaceId, target);
376
+ const result = await createClient().space(spaceId).board(boardId).capabilities();
338
377
  if (jsonRequested(options))
339
378
  return outJson(result);
340
379
  table(result.capabilities.map((capability) => ({
341
380
  ...capability,
342
381
  renderers: capability.renderers?.join(", ") ?? "",
382
+ coordinates: capabilityUnits(capability.schema),
343
383
  })), [
344
384
  { key: "kind", label: "Kind" },
345
385
  { key: "id", label: "ID" },
346
386
  { key: "version", label: "Version" },
347
387
  { key: "renderers", label: "Renderers" },
388
+ { key: "coordinates", label: "Coordinates / units" },
348
389
  { key: "digest", label: "Digest" },
349
390
  ]);
350
391
  const nodes = result.nodes;
@@ -371,19 +412,22 @@ export function registerBoards(program) {
371
412
  });
372
413
  registerTransactionCommand(boards, "validate");
373
414
  registerTransactionCommand(boards, "apply");
415
+ registerBoardDomainCommands(boards);
374
416
  registerExportCommand(boards);
375
- withJson(boards.command("play <board-id> <sequence-id>")
417
+ withJson(boards.command("play <board> <composition-id>")
376
418
  .description("Start shared playback")
377
419
  .option("--position <time>", "Initial position in milliseconds")
378
420
  .option("--time-scale <scale>", "Playback speed from 0 to 4")
379
421
  .option("--seed <seed>", "Deterministic playback seed")
380
422
  .option("--command-id <id>", "Idempotency command ID"))
381
- .action(async (boardId, sequenceId, options) => {
423
+ .action(async (target, compositionId, options) => {
382
424
  try {
383
- const result = await createClient().space(resolveSpace(boards)).board(boardId).play({
425
+ const spaceId = resolveSpace(boards);
426
+ const boardId = await resolveBoardId(spaceId, target);
427
+ const result = await createClient().space(spaceId).board(boardId).play({
384
428
  commandId: commandId(options),
385
429
  type: "play",
386
- sequenceId,
430
+ compositionId,
387
431
  shared: true,
388
432
  ...(options.position === undefined ? {} : { position: parseNumber(options.position, "position", { min: 0 }) }),
389
433
  ...(options.timeScale === undefined ? {} : { timeScale: parseNumber(options.timeScale, "timeScale", { min: Number.EPSILON, max: 4 }) }),
@@ -397,9 +441,11 @@ export function registerBoards(program) {
397
441
  handleHttp(cause);
398
442
  }
399
443
  });
400
- const playbackAction = (type) => async (boardId, playbackId, options) => {
444
+ const playbackAction = (type) => async (target, playbackId, options) => {
401
445
  try {
402
- const board = createClient().space(resolveSpace(boards)).board(boardId);
446
+ const spaceId = resolveSpace(boards);
447
+ const boardId = await resolveBoardId(spaceId, target);
448
+ const board = createClient().space(spaceId).board(boardId);
403
449
  const id = commandId(options);
404
450
  const result = type === "pause"
405
451
  ? await board.pause({ commandId: id, type: "pause", playbackId })
@@ -412,16 +458,18 @@ export function registerBoards(program) {
412
458
  handleHttp(cause);
413
459
  }
414
460
  };
415
- withJson(boards.command("pause <board-id> <playback-id>")
461
+ withJson(boards.command("pause <board> <playback-id>")
416
462
  .description("Pause playback")
417
463
  .option("--command-id <id>", "Idempotency command ID"))
418
464
  .action(playbackAction("pause"));
419
- withJson(boards.command("seek <board-id> <playback-id> <position>")
465
+ withJson(boards.command("seek <board> <playback-id> <position>")
420
466
  .description("Seek playback")
421
467
  .option("--command-id <id>", "Idempotency command ID"))
422
- .action(async (boardId, playbackId, position, options) => {
468
+ .action(async (target, playbackId, position, options) => {
423
469
  try {
424
- const result = await createClient().space(resolveSpace(boards)).board(boardId).seek({
470
+ const spaceId = resolveSpace(boards);
471
+ const boardId = await resolveBoardId(spaceId, target);
472
+ const result = await createClient().space(spaceId).board(boardId).seek({
425
473
  commandId: commandId(options),
426
474
  type: "seek",
427
475
  playbackId,
@@ -435,18 +483,32 @@ export function registerBoards(program) {
435
483
  handleHttp(cause);
436
484
  }
437
485
  });
438
- withJson(boards.command("stop <board-id> <playback-id>")
486
+ withJson(boards.command("stop <board> <playback-id>")
439
487
  .description("Stop playback")
440
488
  .option("--command-id <id>", "Idempotency command ID"))
441
489
  .action(playbackAction("stop"));
442
- withJson(boards.command("watch <board-id>")
490
+ withJson(boards.command("watch <board>")
443
491
  .description("Stream Board events"))
444
- .action((boardId, options) => {
492
+ .action(async (target, options) => {
445
493
  try {
446
- const board = createRealtimeClient().space(resolveSpace(boards)).board(boardId);
494
+ const spaceId = resolveSpace(boards);
495
+ const boardId = await resolveBoardId(spaceId, target);
496
+ const client = createRealtimeClient();
497
+ const board = client.space(spaceId).board(boardId);
447
498
  if (!jsonRequested(options))
448
499
  process.stderr.write(`Listening for Board ${boardId} events...\n`);
449
- board.subscribe({
500
+ const offConnection = client.onConnection((state) => {
501
+ if (jsonRequested(options)) {
502
+ process.stdout.write(`${JSON.stringify({ type: "connection", ...state })}\n`);
503
+ }
504
+ else {
505
+ const detail = state.state === "reconnecting" && state.attempt
506
+ ? ` (attempt ${state.attempt})`
507
+ : "";
508
+ process.stderr.write(`${state.state}${detail}\n`);
509
+ }
510
+ });
511
+ const offBoard = board.subscribe({
450
512
  event(event) {
451
513
  if (jsonRequested(options)) {
452
514
  process.stdout.write(`${JSON.stringify(event)}\n`);
@@ -456,13 +518,18 @@ export function registerBoards(program) {
456
518
  process.stdout.write(`version ${event.payload.version} transaction ${event.payload.txId} operations ${event.payload.operations.length}\n`);
457
519
  }
458
520
  else if (event.type === "board.playback.changed") {
459
- process.stdout.write(`${event.payload.status} sequence ${event.payload.sequenceId} position ${event.payload.position}\n`);
521
+ process.stdout.write(`${event.payload.status} composition ${event.payload.compositionId} position ${event.payload.position}\n`);
460
522
  }
461
523
  else {
462
524
  process.stdout.write(`awareness ${event.payload.actorName} ${event.payload.update.type}\n`);
463
525
  }
464
526
  },
465
527
  });
528
+ process.once("SIGINT", () => {
529
+ offBoard();
530
+ offConnection();
531
+ process.exit(0);
532
+ });
466
533
  }
467
534
  catch (cause) {
468
535
  handleHttp(cause);
@@ -259,7 +259,7 @@ export function registerWorks(program) {
259
259
  .option("--status <status>", "Work status: published, disabled")
260
260
  .option("--visibility <visibility>", "Work visibility: public, space")
261
261
  .option("--work-scope <scope>", "Scope granted to the work runtime (space.view, session.view, file.view, taskrun.view)", collectOption, [])
262
- .option("--viewer-scope <scope>", "Scope viewers may request (session.prompt.readonly, session.prompt.fullaccess, generation.create, user.space.list, user.session.list, user.usage.read)", collectOption, [])
262
+ .option("--viewer-scope <scope>", "Scope viewers may request (taskrun.view, session.prompt.readonly, session.prompt.fullaccess, generation.create, user.space.list, user.session.list, user.usage.read)", collectOption, [])
263
263
  .option("--meta <json>", "Work metadata as a JSON object")
264
264
  .option("--hide-cohub-bar", "Hide the Cohub footer bar on the public work page")
265
265
  .option("--show-cohub-bar", "Show the Cohub footer bar on the public work page")
@@ -338,7 +338,7 @@ export function registerWorks(program) {
338
338
  .option("--status <status>", "Work status: published, disabled")
339
339
  .option("--visibility <visibility>", "Work visibility: public, space")
340
340
  .option("--work-scope <scope>", "Scope granted to the work runtime (space.view, session.view, file.view, taskrun.view)", collectOption, [])
341
- .option("--viewer-scope <scope>", "Scope viewers may request (session.prompt.readonly, session.prompt.fullaccess, generation.create, user.space.list, user.session.list, user.usage.read)", collectOption, [])
341
+ .option("--viewer-scope <scope>", "Scope viewers may request (taskrun.view, session.prompt.readonly, session.prompt.fullaccess, generation.create, user.space.list, user.session.list, user.usage.read)", collectOption, [])
342
342
  .option("--clear-work-scopes", "Clear work runtime scopes")
343
343
  .option("--clear-viewer-scopes", "Clear viewer-requestable scopes")
344
344
  .option("--meta <json>", "Work metadata as a JSON object")
@@ -0,0 +1,24 @@
1
+ export declare const REMOTE_IMAGE_MAX_BYTES: number;
2
+ export declare const REMOTE_IMAGE_TIMEOUT_MS = 15000;
3
+ type ResolvedAddress = {
4
+ address: string;
5
+ family: 4 | 6;
6
+ };
7
+ type Lookup = (hostname: string) => Promise<readonly ResolvedAddress[]>;
8
+ type RemoteResponse = {
9
+ status: number;
10
+ headers: Headers;
11
+ bytes: Uint8Array;
12
+ };
13
+ type Requester = (url: URL, address: ResolvedAddress, timeoutMs: number, maxBytes: number) => Promise<RemoteResponse>;
14
+ export type RemoteImageDownloadOptions = {
15
+ lookup?: Lookup;
16
+ requester?: Requester;
17
+ maxBytes?: number;
18
+ timeoutMs?: number;
19
+ };
20
+ export declare function downloadPublicImage(input: string, options?: RemoteImageDownloadOptions): Promise<{
21
+ bytes: Uint8Array;
22
+ mimeType: string;
23
+ }>;
24
+ export {};
@@ -0,0 +1,160 @@
1
+ import dns from "node:dns/promises";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import { isIP } from "node:net";
5
+ import { isPublicBoardRemoteAddress, normalizeBoardRemoteUrl, } from "@neta-art/cohub/board";
6
+ export const REMOTE_IMAGE_MAX_BYTES = 16 * 1024 * 1024;
7
+ export const REMOTE_IMAGE_TIMEOUT_MS = 15_000;
8
+ const MAX_REDIRECTS = 3;
9
+ const IMAGE_MIME_TYPES = new Set([
10
+ "image/avif",
11
+ "image/gif",
12
+ "image/jpeg",
13
+ "image/png",
14
+ "image/webp",
15
+ ]);
16
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
17
+ async function defaultLookup(hostname) {
18
+ const records = await dns.lookup(hostname, { all: true, verbatim: true });
19
+ return records.map((record) => ({
20
+ address: record.address,
21
+ family: record.family,
22
+ }));
23
+ }
24
+ function responseHeaders(input) {
25
+ const headers = new Headers();
26
+ for (const [key, value] of Object.entries(input)) {
27
+ if (Array.isArray(value)) {
28
+ for (const item of value)
29
+ headers.append(key, item);
30
+ }
31
+ else if (value !== undefined) {
32
+ headers.set(key, value);
33
+ }
34
+ }
35
+ return headers;
36
+ }
37
+ function requestPinned(url, address, timeoutMs, maxBytes) {
38
+ return new Promise((resolve, reject) => {
39
+ const client = url.protocol === "https:" ? https : http;
40
+ const request = client.request(url, {
41
+ agent: false,
42
+ headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
43
+ lookup: (_hostname, _options, callback) => {
44
+ callback(null, address.address, address.family);
45
+ },
46
+ }, (response) => {
47
+ const status = response.statusCode ?? 0;
48
+ const headers = responseHeaders(response.headers);
49
+ if (REDIRECT_STATUSES.has(status)) {
50
+ response.resume();
51
+ resolve({ status, headers, bytes: new Uint8Array() });
52
+ return;
53
+ }
54
+ const declaredLength = Number(headers.get("content-length") ?? 0);
55
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
56
+ response.destroy();
57
+ reject(new Error(`Image exceeds the ${maxBytes} byte download limit`));
58
+ return;
59
+ }
60
+ const chunks = [];
61
+ let total = 0;
62
+ response.on("data", (chunk) => {
63
+ total += chunk.byteLength;
64
+ if (total > maxBytes) {
65
+ response.destroy(new Error(`Image exceeds the ${maxBytes} byte download limit`));
66
+ return;
67
+ }
68
+ chunks.push(chunk);
69
+ });
70
+ response.once("error", reject);
71
+ response.once("end", () => {
72
+ const bytes = new Uint8Array(total);
73
+ let offset = 0;
74
+ for (const chunk of chunks) {
75
+ bytes.set(chunk, offset);
76
+ offset += chunk.byteLength;
77
+ }
78
+ resolve({ status, headers, bytes });
79
+ });
80
+ });
81
+ request.setTimeout(timeoutMs, () => {
82
+ request.destroy(new Error(`Image download timed out after ${timeoutMs}ms`));
83
+ });
84
+ request.once("error", reject);
85
+ request.end();
86
+ });
87
+ }
88
+ function remainingMs(deadline) {
89
+ const remaining = deadline - Date.now();
90
+ if (remaining <= 0)
91
+ throw new Error("Image download timed out");
92
+ return remaining;
93
+ }
94
+ async function withDeadline(promise, deadline) {
95
+ const timeoutMs = remainingMs(deadline);
96
+ let timer;
97
+ try {
98
+ return await Promise.race([
99
+ promise,
100
+ new Promise((_, reject) => {
101
+ timer = setTimeout(() => reject(new Error(`Image download timed out after ${timeoutMs}ms`)), timeoutMs);
102
+ }),
103
+ ]);
104
+ }
105
+ finally {
106
+ if (timer)
107
+ clearTimeout(timer);
108
+ }
109
+ }
110
+ async function resolvePublicUrl(value, lookup, deadline) {
111
+ const normalized = normalizeBoardRemoteUrl(value);
112
+ if (!normalized)
113
+ throw new Error("Image URL must be a public HTTP(S) URL");
114
+ const url = new URL(normalized);
115
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
116
+ const addresses = isIP(hostname)
117
+ ? [{ address: hostname, family: isIP(hostname) }]
118
+ : await withDeadline(lookup(hostname), deadline);
119
+ if (addresses.length === 0 ||
120
+ addresses.some((entry) => !isPublicBoardRemoteAddress(entry.address))) {
121
+ throw new Error("Image URL resolves to a private address");
122
+ }
123
+ return { url, address: addresses[0] };
124
+ }
125
+ export async function downloadPublicImage(input, options = {}) {
126
+ const lookup = options.lookup ?? defaultLookup;
127
+ const requester = options.requester ?? requestPinned;
128
+ const maxBytes = options.maxBytes ?? REMOTE_IMAGE_MAX_BYTES;
129
+ const deadline = Date.now() + (options.timeoutMs ?? REMOTE_IMAGE_TIMEOUT_MS);
130
+ let current = input;
131
+ for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
132
+ const { url, address } = await resolvePublicUrl(current, lookup, deadline);
133
+ const response = await withDeadline(requester(url, address, remainingMs(deadline), maxBytes), deadline);
134
+ if (REDIRECT_STATUSES.has(response.status)) {
135
+ const location = response.headers.get("location");
136
+ if (!location)
137
+ throw new Error("Image redirect is missing a location");
138
+ if (redirect === MAX_REDIRECTS)
139
+ throw new Error("Too many image redirects");
140
+ current = new URL(location, url).toString();
141
+ continue;
142
+ }
143
+ if (response.status < 200 || response.status >= 300) {
144
+ throw new Error(`HTTP ${response.status}`);
145
+ }
146
+ if (response.bytes.byteLength > maxBytes) {
147
+ throw new Error(`Image exceeds the ${maxBytes} byte download limit`);
148
+ }
149
+ const mimeType = response.headers
150
+ .get("content-type")
151
+ ?.split(";", 1)[0]
152
+ ?.trim()
153
+ .toLowerCase();
154
+ if (!mimeType || !IMAGE_MIME_TYPES.has(mimeType)) {
155
+ throw new Error("Remote background must be a supported raster image");
156
+ }
157
+ return { bytes: response.bytes, mimeType };
158
+ }
159
+ throw new Error("Too many image redirects");
160
+ }