@synapse-sdk/client 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // `destroyed` ends the handle. Writes name the epoch the mirror holds; the runtime checks ownership
9
9
  // (`not_owner`). Between incarnations (a session change, a replay) nothing is known: `store_gone`.
10
10
  import * as Moq from "@moq/net";
11
- import { FLAGS, STORE_CONFIG, STORE_TRACK, SynapseError, decode as decodeBody, decodeHeader, moqPriority, storeBasePath, } from "@synapse-sdk/core";
11
+ import { FLAGS, STORE_TRACK, SynapseError, decode as decodeBody, decodeHeader, moqPriority, storeBasePath, storeRobotScoped, } from "@synapse-sdk/core";
12
12
  import { startOn } from "./calls.js";
13
13
  import { REFOLLOW_PACE_MS, holderOf, isTransient, refusalPaceMs } from "./channel.js";
14
14
  import { latestSubscription, waitActive } from "./session.js";
@@ -224,7 +224,8 @@ export function startNamespace(ns, registry) { void run(ns, registry); }
224
224
  async function run(ns, registry) {
225
225
  const { name, opts, state, warn, node } = ns;
226
226
  const post = (e) => { ns.post(e); };
227
- const config = name === STORE_CONFIG;
227
+ /** A robot-scoped namespace belongs to the robot: a project switch leaves it as it is. */
228
+ const robotScoped = storeRobotScoped(name);
228
229
  let session;
229
230
  let phase = { kind: "idle" };
230
231
  const ph = () => phase;
@@ -310,7 +311,7 @@ async function run(ns, registry) {
310
311
  state.reset();
311
312
  unwatchProject?.();
312
313
  unwatchProject = undefined;
313
- if (s && !config) {
314
+ if (s && !robotScoped) {
314
315
  const feed = s.project;
315
316
  const current = feed.get();
316
317
  if (known === undefined)
@@ -542,7 +543,7 @@ async function run(ns, registry) {
542
543
  }
543
544
  break;
544
545
  case "projectChanged":
545
- if (!config)
546
+ if (!robotScoped)
546
547
  projectChanged(ev.now);
547
548
  break;
548
549
  case "paced":
@@ -843,6 +844,8 @@ export class StoreHandleImpl {
843
844
  subscribe(listener) { return this.ns.state.subscribe(listener); }
844
845
  /** Waits for the namespace to be in (the first snapshot applied, or absent). */
845
846
  join() { return this.ns.request(reply => ({ kind: "join", reply })); }
847
+ /** `join()` under the SDK's word (Publisher and Registration have `ready()` too). */
848
+ ready() { return this.join(); }
846
849
  close() {
847
850
  if (this.#closed)
848
851
  return;
@@ -896,3 +899,26 @@ export async function openStore(node, registry, name, opts, warn) {
896
899
  return h;
897
900
  }
898
901
  }
902
+ /**
903
+ * The synchronous form of `openStore`, for a handle the node itself holds (`node.tasks`): a handle on
904
+ * the namespace's task, joined if one runs, else started now; the value is in once `ready()`
905
+ * resolves. A task that is ending (its last handle closed) is waited out before the new one starts:
906
+ * the new one is in the registry at once (a `store()` meanwhile joins it), its requests wait in its
907
+ * mailbox.
908
+ */
909
+ export function attachStore(node, registry, name, opts, warn) {
910
+ const live = registry.get(name);
911
+ if (live && live.handles > 0) {
912
+ if (live.opts.access !== opts.access || live.opts.onDeath !== opts.onDeath)
913
+ throw new SynapseError("invalid_input", `store ${JSON.stringify(name)}: this node already has it open with other options`);
914
+ return new StoreHandleImpl(live);
915
+ }
916
+ const ns = new Namespace(name, opts, node, warn);
917
+ registry.set(name, ns);
918
+ const h = new StoreHandleImpl(ns);
919
+ if (live)
920
+ void live.exited.promise.then(() => startNamespace(ns, registry));
921
+ else
922
+ startNamespace(ns, registry);
923
+ return h;
924
+ }
package/dist/synapse.d.ts CHANGED
@@ -2,12 +2,11 @@ import { type Age, type ClockInfo, type ErrorBody, type LocalTime, type ProjectS
2
2
  import { Node } from "./node.ts";
3
3
  import { StoreHandleImpl } from "./store.ts";
4
4
  import { type Warn } from "./topics.ts";
5
- import type { CallEvent, CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Envelope, Info, Metrics, Notice, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, View, ViewOptions } from "./types.ts";
5
+ import type { CallEvent, CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Envelope, Info, Metrics, Notice, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
6
6
  /** How long the first `connect()` keeps trying a retryable failure before `failed` (SDK §2: the first connect is bounded). */
7
7
  export declare const FIRST_CONNECT_GIVE_UP_MS = 30000;
8
8
  export declare class Synapse implements AsyncDisposable {
9
9
  #private;
10
- readonly opts: ConnectOptions;
11
10
  warn: Warn;
12
11
  constructor(opts: ConnectOptions);
13
12
  /** `await Synapse.connect(opts)`: a node with a session, or the reason there is none. */
@@ -85,5 +84,10 @@ export declare class Synapse implements AsyncDisposable {
85
84
  /** `synapse/project/leave {}`: drops our standing (every session of our `user_id`). */
86
85
  leaveProject(): Promise<ProjectSnapshot>;
87
86
  getProject(): Promise<ProjectSnapshot>;
87
+ /**
88
+ * The robot's task declarations, what runs (`status`), what the runtime can run (`catalog`), and the verbs; the same
89
+ * object for the node's life. Each handle opens on its first touch and is the node's own (`node.close()` ends it).
90
+ */
91
+ get tasks(): Tasks;
88
92
  }
89
93
  export type { Envelope, StoreHandleImpl };
package/dist/synapse.js CHANGED
@@ -7,7 +7,8 @@ import { callStream, startOn } from "./calls.js";
7
7
  import { Node, resolveTarget, startNode } from "./node.js";
8
8
  import { ServiceRegistration } from "./serve.js";
9
9
  import { openScene } from "./scene.js";
10
- import { Namespace, StoreHandleImpl, openStore } from "./store.js";
10
+ import { Namespace, StoreHandleImpl, attachStore, openStore } from "./store.js";
11
+ import { TasksFacet } from "./tasks.js";
11
12
  import { PublisherHandle, TopicPublisher, TopicSubscription, wasmFor } from "./topics.js";
12
13
  import { TopicView } from "./view.js";
13
14
  /** How long the first `connect()` keeps trying a retryable failure before `failed` (SDK §2: the first connect is bounded). */
@@ -21,18 +22,19 @@ function wantedDesc(contract, n) {
21
22
  }
22
23
  /** The parts of a descriptor two publishers of one topic on one node must agree on, as text (also for the refusal's message). */
23
24
  function descKey(d) {
24
- return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null, fec: d.fec ?? null, layout: d.layout ?? null });
25
+ return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null, fec: d.fec ?? null });
25
26
  }
26
27
  export class Synapse {
27
- opts;
28
28
  #node;
29
29
  #connecting;
30
30
  #starting;
31
31
  #publishers = new Map();
32
32
  #stores = new Map();
33
33
  warn;
34
+ // Private: the options hold the token and the credential, which nothing prints.
35
+ #opts;
34
36
  constructor(opts) {
35
- this.opts = opts;
37
+ this.#opts = opts;
36
38
  this.warn = msg => console.warn(`[synapse] ${msg}`);
37
39
  }
38
40
  /** `await Synapse.connect(opts)`: a node with a session, or the reason there is none. */
@@ -40,7 +42,7 @@ export class Synapse {
40
42
  /** Connects (once; later calls return the same promise). Resolves once a session is established. */
41
43
  connect() {
42
44
  this.#connecting ??= (async () => {
43
- const started = startNode(this.opts, FIRST_CONNECT_GIVE_UP_MS);
45
+ const started = startNode(this.#opts, FIRST_CONNECT_GIVE_UP_MS);
44
46
  // The node as soon as its loop runs (or none: the options were refused), for a `close()` during the first dial.
45
47
  this.#starting = started.then(s => s.node, () => undefined);
46
48
  const { node, connected } = await started;
@@ -93,7 +95,7 @@ export class Synapse {
93
95
  const n = this.node;
94
96
  const id = n.target.get().identity;
95
97
  const s = n.currentSession();
96
- return { name: id.name ?? "", type: id.type, userId: id.user_id, prefix: s?.prefix ?? "", role: s?.role ?? "observer", dom: n.dom, brokerDom: s?.hello.dom ?? "", brokerVersion: s?.hello.broker_version ?? "", robot: s?.hello.robot ?? "", url: s?.url ?? n.target.get().dial?.url ?? "" };
98
+ return { name: id.name ?? "", type: id.type, userId: id.user_id, prefix: s?.prefix ?? "", role: s?.role ?? "observer", dom: n.dom, brokerDom: s?.hello.dom ?? "", brokerVersion: s?.hello.broker_version ?? "", robot: s?.hello.robot ?? "", runtimeName: s?.hello.runtime_name ?? "", runtimeId: s?.hello.runtime_id ?? "", url: n.target.get().dial?.url ?? "" };
97
99
  }
98
100
  metrics() { const n = this.node; return { sessions: n.epochs, reconnects: n.reconnects, callsSent: n.callsSent, callsAnswered: n.callsAnswered }; }
99
101
  /** Broker notices addressed to this node (an error body on `res` without a call). */
@@ -253,4 +255,14 @@ export class Synapse {
253
255
  /** `synapse/project/leave {}`: drops our standing (every session of our `user_id`). */
254
256
  leaveProject() { return this.#projectCall("leave", {}); }
255
257
  getProject() { return this.#projectCall("get", {}); }
258
+ // -------------------------------------------------------------------------------- tasks
259
+ #tasks;
260
+ /**
261
+ * The robot's task declarations, what runs (`status`), what the runtime can run (`catalog`), and the verbs; the same
262
+ * object for the node's life. Each handle opens on its first touch and is the node's own (`node.close()` ends it).
263
+ */
264
+ get tasks() {
265
+ const n = this.node;
266
+ return (this.#tasks ??= new TasksFacet(name => attachStore(n, this.#stores, name, {}, m => this.warn(m)), svc => this.call(svc, {}).result, m => this.warn(m)));
267
+ }
256
268
  }
@@ -0,0 +1,37 @@
1
+ import { SynapseError, type TasksReportItem, type TasksRun } from "@synapse-sdk/core";
2
+ import type { StoreHandleImpl } from "./store.ts";
3
+ import type { ReadonlyStoreHandle, TaskCatalogEntry, TaskDeclarations, Tasks, TasksStatus } from "./types.ts";
4
+ import type { Warn } from "./topics.ts";
5
+ /** The namespaces the facet reads and writes (the runtime's names). */
6
+ export declare const TASKS_NAMESPACES: {
7
+ readonly declarations: "tasks";
8
+ readonly status: "task_status";
9
+ readonly catalog: "task_catalog";
10
+ };
11
+ /** What `status.value` is before the runtime said anything: neither scope's state known, no tasks. */
12
+ export declare const EMPTY_TASKS_STATUS: TasksStatus;
13
+ /**
14
+ * `task_status`'s rows as a `TasksStatus`. A run row is the one keyed `robot` or `project`; an instance
15
+ * row goes where its own `scope`, `entity` and `instance` say (its key is never parsed: an instance
16
+ * name may hold `:`). A row that cannot be placed is left out and passed to `unplaced` (the runtime
17
+ * never writes one). The instance rows are the mirror's own objects.
18
+ */
19
+ export declare function tasksStatusOf(rows: Readonly<Record<string, unknown>>, unplaced?: (key: string) => void): TasksStatus;
20
+ /** `node.tasks`. `attach(name)` hands out the node's handle on a namespace; `call` starts a call on the current session. */
21
+ export declare class TasksFacet implements Tasks {
22
+ private readonly call;
23
+ readonly declarations: TaskDeclarations;
24
+ readonly status: ReadonlyStoreHandle<TasksStatus>;
25
+ readonly catalog: ReadonlyStoreHandle<Readonly<Record<string, TaskCatalogEntry>>>;
26
+ constructor(attach: (name: string) => StoreHandleImpl, call: (svc: string) => Promise<unknown>, warn: Warn);
27
+ runRobotTasks(): Promise<TasksRun>;
28
+ stopRobotTasks(): Promise<void>;
29
+ runProjectTasks(): Promise<TasksRun>;
30
+ stopProjectTasks(): Promise<void>;
31
+ }
32
+ /** `true` for a refused run, `preflight_failed` or `launch_failed`, whose `data.report` names each declaration at fault and why. */
33
+ export declare function isTasksRefusal(e: unknown): e is SynapseError & {
34
+ readonly data: {
35
+ report: TasksReportItem[];
36
+ };
37
+ };
package/dist/tasks.js ADDED
@@ -0,0 +1,132 @@
1
+ // `node.tasks` (PLAN v0.29 SDK pass 2): the robot's task declarations (namespace `tasks`), what runs
2
+ // (`task_status`, read-only, typed by scope), what the runtime can run (`task_catalog`, read-only), and
3
+ // the four verbs. Each handle is a handle on the node's own namespace task (`store.ts`), opened on its
4
+ // first touch and held for the node's life: `node.store("task_status")` and `node.tasks.status` are one
5
+ // subscription. Nothing here is decided: the mirror is the runtime's, the verbs' replies are its
6
+ // answers, and the typed values are computed from the mirror (no copy), with structural sharing so an
7
+ // unchanged part keeps its object.
8
+ import { SynapseError } from "@synapse-sdk/core";
9
+ import { replaceEqualDeep } from "./util.js";
10
+ /** The namespaces the facet reads and writes (the runtime's names). */
11
+ export const TASKS_NAMESPACES = { declarations: "tasks", status: "task_status", catalog: "task_catalog" };
12
+ /** What `status.value` is before the runtime said anything: neither scope's state known, no tasks. */
13
+ export const EMPTY_TASKS_STATUS = { robot: { state: undefined, tasks: {} }, project: { state: undefined, tasks: {} } };
14
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
15
+ /** A run row (`robot` / `project`: `{state, since, reason?}`) as the scope's run fields. */
16
+ function runOf(row) {
17
+ if (!isObject(row))
18
+ return { state: undefined };
19
+ const out = { state: row.state };
20
+ if (row.since !== undefined)
21
+ out.since = row.since;
22
+ if (row.reason !== undefined)
23
+ out.reason = row.reason;
24
+ return out;
25
+ }
26
+ /**
27
+ * `task_status`'s rows as a `TasksStatus`. A run row is the one keyed `robot` or `project`; an instance
28
+ * row goes where its own `scope`, `entity` and `instance` say (its key is never parsed: an instance
29
+ * name may hold `:`). A row that cannot be placed is left out and passed to `unplaced` (the runtime
30
+ * never writes one). The instance rows are the mirror's own objects.
31
+ */
32
+ export function tasksStatusOf(rows, unplaced = () => { }) {
33
+ const robot = {};
34
+ const project = {};
35
+ for (const [key, row] of Object.entries(rows)) {
36
+ if (key === "robot" || key === "project")
37
+ continue;
38
+ if (!isObject(row) || typeof row.instance !== "string") {
39
+ unplaced(key);
40
+ continue;
41
+ }
42
+ if (row.scope === "robot")
43
+ robot[row.instance] = row;
44
+ else if (row.scope === "project" && typeof row.entity === "string")
45
+ (project[row.entity] ??= {})[row.instance] = row;
46
+ else
47
+ unplaced(key);
48
+ }
49
+ return { robot: { ...runOf(rows.robot), tasks: robot }, project: { ...runOf(rows.project), tasks: project } };
50
+ }
51
+ /**
52
+ * A read-only handle over one namespace of the node: `value` is `derive` of the mirror, recomputed only
53
+ * when the mirror changed and shared structurally with the previous value. The namespace is opened on
54
+ * the first touch of any member.
55
+ */
56
+ class DerivedHandle {
57
+ open;
58
+ derive;
59
+ #h;
60
+ #from;
61
+ #value;
62
+ constructor(open, derive, empty) {
63
+ this.open = open;
64
+ this.derive = derive;
65
+ this.#value = empty;
66
+ }
67
+ #handle() { return (this.#h ??= this.open()); }
68
+ get value() {
69
+ const rows = this.#handle().value;
70
+ if (rows !== this.#from) {
71
+ this.#from = rows;
72
+ this.#value = replaceEqualDeep(this.#value, this.derive(rows));
73
+ }
74
+ return this.#value;
75
+ }
76
+ get synced() { return this.#handle().synced; }
77
+ ready() { return this.#handle().ready(); }
78
+ async *watch() {
79
+ for await (const _ of this.#handle().watch())
80
+ yield this.value;
81
+ }
82
+ subscribe(listener) { return this.#handle().subscribe(listener); }
83
+ }
84
+ /** The robot's declarations: the node's handle on `tasks`; every write waits for the value to be current first. */
85
+ class Declarations {
86
+ open;
87
+ #h;
88
+ constructor(open) {
89
+ this.open = open;
90
+ }
91
+ #handle() { return (this.#h ??= this.open()); }
92
+ get value() { return this.#handle().value; }
93
+ get(instance) { return this.#handle().get(instance); }
94
+ get synced() { return this.#handle().synced; }
95
+ get status() { return this.#handle().status; }
96
+ ready() { return this.#handle().ready(); }
97
+ async set(a, b) {
98
+ const h = this.#handle();
99
+ await h.ready();
100
+ await (typeof a === "string" ? h.set(a, b) : h.set(a));
101
+ }
102
+ async delete(instance) { const h = this.#handle(); await h.ready(); await h.delete(instance); }
103
+ async clear() { const h = this.#handle(); await h.ready(); await h.clear(); }
104
+ watch(instance) { return this.#handle().watch(instance); }
105
+ subscribe(listener) { return this.#handle().subscribe(listener); }
106
+ }
107
+ /** `node.tasks`. `attach(name)` hands out the node's handle on a namespace; `call` starts a call on the current session. */
108
+ export class TasksFacet {
109
+ call;
110
+ declarations;
111
+ status;
112
+ catalog;
113
+ constructor(attach, call, warn) {
114
+ this.call = call;
115
+ const warned = new Set();
116
+ const unplaced = (key) => { if (!warned.has(key)) {
117
+ warned.add(key);
118
+ warn(`tasks: status row ${JSON.stringify(key)} names no scope and instance; left out`);
119
+ } };
120
+ this.declarations = new Declarations(() => attach(TASKS_NAMESPACES.declarations));
121
+ this.status = new DerivedHandle(() => attach(TASKS_NAMESPACES.status), rows => tasksStatusOf(rows, unplaced), EMPTY_TASKS_STATUS);
122
+ this.catalog = new DerivedHandle(() => attach(TASKS_NAMESPACES.catalog), rows => rows, {});
123
+ }
124
+ async runRobotTasks() { return (await this.call("synapse/tasks/run_robot")); }
125
+ async stopRobotTasks() { await this.call("synapse/tasks/stop_robot"); }
126
+ async runProjectTasks() { return (await this.call("synapse/tasks/run_project")); }
127
+ async stopProjectTasks() { await this.call("synapse/tasks/stop_project"); }
128
+ }
129
+ /** `true` for a refused run, `preflight_failed` or `launch_failed`, whose `data.report` names each declaration at fault and why. */
130
+ export function isTasksRefusal(e) {
131
+ return e instanceof SynapseError && (e.code === "preflight_failed" || e.code === "launch_failed") && isObject(e.data) && Array.isArray(e.data.report);
132
+ }
package/dist/topics.d.ts CHANGED
@@ -39,10 +39,22 @@ export declare class TopicPublisher<TIn, TOut> implements Intent {
39
39
  * frame the codec refuses is refused (`invalid_input`) without taking a `seq`.
40
40
  */
41
41
  publishFrame(type: number, flags: number, tUs: number, tlvs: Tlv[], body: Uint8Array): void;
42
- /** The typed form: validates against the contract's schema, encodes (msgpack, or the bytes of a raw topic). */
42
+ /**
43
+ * The typed form: validates against the contract's schema and encodes msgpack; a raw topic's bytes
44
+ * go as given, and on a `video/*` topic as a `MEDIA` frame whose `KEY`/`CONFIG`/`RECOVERY` flags
45
+ * are read off the bitstream (Rust `Publisher<RawBody>::publish`).
46
+ */
43
47
  publish(value: TIn, opts?: PublishOptions): void;
48
+ /** An encoder's frame with the flags it already carries and its `STAGES` stamps (no re-parse); `tUs` 0 = now. The video helper's path (Rust `publish_media_frame`), not a user verb. */
49
+ publishMediaFrame(f: MediaFrame): void;
44
50
  stats(): PublisherStats;
45
51
  }
52
+ /**
53
+ * The key of a publisher handle's encoder-frame path, the JS spelling of Rust's `pub(crate)
54
+ * publish_media_frame`: `@synapse-sdk/media`'s `videoPublisher` reaches it by the same
55
+ * `Symbol.for` key (the two packages share no module), so it is on no public type.
56
+ */
57
+ export declare const PUBLISH_MEDIA_FRAME: unique symbol;
46
58
  /** The application's handle: the last one closed ends the publisher's task (`unregister`). */
47
59
  export declare class PublisherHandle<TIn, TOut> implements Publisher<TIn> {
48
60
  #private;
@@ -53,10 +65,8 @@ export declare class PublisherHandle<TIn, TOut> implements Publisher<TIn> {
53
65
  get verdict(): Verdict;
54
66
  ready(): Promise<void>;
55
67
  publish(value: TIn, opts?: PublishOptions): void;
56
- /** Any frame type and TLVs (`MEDIA` with `STAGES`, WP §4); raw topics only. */
57
- publishFrame(type: number, flags: number, tUs: number, tlvs: Tlv[], body: Uint8Array): void;
58
- /** `tUs` 0 = capture now (as the Rust client stamps it). */
59
- publishMedia(f: MediaFrame): void;
68
+ /** `@synapse-sdk/media`'s video publisher: an encoder's frame with its flags and `STAGES` (see `PUBLISH_MEDIA_FRAME`). */
69
+ [PUBLISH_MEDIA_FRAME](f: MediaFrame): void;
60
70
  stats(): PublisherStats;
61
71
  close(): void;
62
72
  [Symbol.asyncDispose](): Promise<void>;
@@ -119,7 +129,12 @@ export declare class TopicSubscription<T> implements Subscription<T> {
119
129
  dropFecOf(publisher: string): void;
120
130
  /** A new session (or none): the `fec` frames and a batch held by `throttleMs` are the previous session's and go. */
121
131
  clearFec(): void;
122
- /** The decoding step: header facts plus the body through the contract, or `mismatch` and dropped. */
132
+ /**
133
+ * The established descriptor's `content_type` (the catalog's, the runtime's statement), set by the
134
+ * follow task from each snapshot: bodies decode by it, not by what this subscription declared.
135
+ */
136
+ contentType: string | undefined;
137
+ /** The decoding step: header facts plus the body by the established `content_type` (msgpack → a value, validated when the contract has a schema; anything else → bytes), or `mismatch` and dropped. */
123
138
  envelope(payload: Uint8Array, publisher: string, dom: string): Envelope<T> | undefined;
124
139
  /**
125
140
  * The next batch, never empty; `undefined` once the subscription or the node ended. Drains what
package/dist/topics.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // when every listed publisher not held is tried again; no timer of our own. A datagram publisher
13
13
  // whose own `desc` has `fec` gets a follower (the video path: `FecReader` per publisher).
14
14
  import * as Moq from "@moq/net";
15
- import { FLAGS, MSGPACK, SynapseError, TYPES, decode as decodeBody, decodeHeader, encode as encodeBody, fecSymbolSizeFor, frame, moqPriority, nowUs, stampSentUs, hasPendingSentUs, mediaHeader, topicPath, validateSync, } from "@synapse-sdk/core";
15
+ import { FLAGS, MSGPACK, SynapseError, TYPES, auParamSets, auSyncPoints, decode as decodeBody, decodeHeader, encode as encodeBody, fecSymbolSizeFor, frame, isVideo, mediaFlags, moqPriority, nowUs, stampSentUs, hasPendingSentUs, mediaHeader, topicPath, validateSync, } from "@synapse-sdk/core";
16
16
  import { Handle, start as startHandle } from "./channel.js";
17
17
  import { FecReader, FecWriter, ReportWriter, feedbackListener, requireFec } from "./fec.js";
18
18
  import { RETAINED_FRAME_MAX_AGE_MS, StreamWriter, latestSubscription, moqTimestamp, streamTopicSubscription, waitActive, writeLatest } from "./session.js";
@@ -152,26 +152,35 @@ export class TopicPublisher {
152
152
  else
153
153
  this.droppedDisconnected++;
154
154
  }
155
- /** The typed form: validates against the contract's schema, encodes (msgpack, or the bytes of a raw topic). */
155
+ /**
156
+ * The typed form: validates against the contract's schema and encodes msgpack; a raw topic's bytes
157
+ * go as given, and on a `video/*` topic as a `MEDIA` frame whose `KEY`/`CONFIG`/`RECOVERY` flags
158
+ * are read off the bitstream (Rust `Publisher<RawBody>::publish`).
159
+ */
156
160
  publish(value, opts = {}) {
157
- let body;
161
+ const tUs = opts.tUs ?? nowUs();
158
162
  if (this.contract.raw) {
159
163
  if (!(value instanceof Uint8Array))
160
164
  throw new SynapseError("invalid_input", `topic ${this.name} (${this.contract.contentType}) takes bytes`);
161
- body = value;
162
- }
163
- else {
164
- let v = value;
165
- if (this.contract.schema) {
166
- const r = validateSync(this.contract.schema, value);
167
- if (r.issues)
168
- throw new SynapseError("invalid_input", { message: r.issues[0]?.message ?? "invalid value", data: { issues: r.issues } });
169
- v = r.value;
165
+ if (!isVideo(this.contract.contentType)) {
166
+ this.publishFrame(TYPES.PUB, opts.flags ?? 0, tUs, [], value);
167
+ return;
170
168
  }
171
- body = encodeBody(v);
169
+ const { idr, recovery } = auSyncPoints(value);
170
+ this.publishFrame(TYPES.MEDIA, mediaFlags({ key: idr, config: auParamSets(value) !== undefined, recovery }) | (opts.flags ?? 0), tUs, [], value);
171
+ return;
172
+ }
173
+ let v = value;
174
+ if (this.contract.schema) {
175
+ const r = validateSync(this.contract.schema, value);
176
+ if (r.issues)
177
+ throw new SynapseError("invalid_input", { message: r.issues[0]?.message ?? "invalid value", data: { issues: r.issues } });
178
+ v = r.value;
172
179
  }
173
- this.publishFrame(TYPES.PUB, opts.flags ?? 0, opts.tUs ?? nowUs(), [], body);
180
+ this.publishFrame(TYPES.PUB, opts.flags ?? 0, tUs, [], encodeBody(v));
174
181
  }
182
+ /** An encoder's frame with the flags it already carries and its `STAGES` stamps (no re-parse); `tUs` 0 = now. The video helper's path (Rust `publish_media_frame`), not a user verb. */
183
+ publishMediaFrame(f) { const h = mediaHeader(f, 0); this.publishFrame(h.type, h.flags, f.tUs || nowUs(), h.tlvs ?? [], f.data); }
175
184
  stats() {
176
185
  const s = { published: this.published, droppedDisconnected: this.droppedDisconnected };
177
186
  if (this.fecStats)
@@ -199,6 +208,12 @@ function finish(b) {
199
208
  }
200
209
  catch { /* closed */ }
201
210
  }
211
+ /**
212
+ * The key of a publisher handle's encoder-frame path, the JS spelling of Rust's `pub(crate)
213
+ * publish_media_frame`: `@synapse-sdk/media`'s `videoPublisher` reaches it by the same
214
+ * `Symbol.for` key (the two packages share no module), so it is on no public type.
215
+ */
216
+ export const PUBLISH_MEDIA_FRAME = Symbol.for("@synapse-sdk/client:publishMediaFrame");
202
217
  /** The application's handle: the last one closed ends the publisher's task (`unregister`). */
203
218
  export class PublisherHandle {
204
219
  p;
@@ -216,10 +231,8 @@ export class PublisherHandle {
216
231
  #open() { if (this.#closed)
217
232
  throw new SynapseError("not_connected", "publisher closed"); }
218
233
  publish(value, opts) { this.#open(); this.p.publish(value, opts); }
219
- /** Any frame type and TLVs (`MEDIA` with `STAGES`, WP §4); raw topics only. */
220
- publishFrame(type, flags, tUs, tlvs, body) { this.#open(); this.p.publishFrame(type, flags, tUs, tlvs, body); }
221
- /** `tUs` 0 = capture now (as the Rust client stamps it). */
222
- publishMedia(f) { this.#open(); const h = mediaHeader(f, 0); this.p.publishFrame(h.type, h.flags, f.tUs || nowUs(), h.tlvs ?? [], f.data); }
234
+ /** `@synapse-sdk/media`'s video publisher: an encoder's frame with its flags and `STAGES` (see `PUBLISH_MEDIA_FRAME`). */
235
+ [PUBLISH_MEDIA_FRAME](f) { this.#open(); this.p.publishMediaFrame(f); }
223
236
  stats() { return this.p.stats(); }
224
237
  close() {
225
238
  if (this.#closed)
@@ -415,7 +428,12 @@ export class TopicSubscription {
415
428
  /** A new session (or none): the `fec` frames and a batch held by `throttleMs` are the previous session's and go. */
416
429
  clearFec() { this.#fec.length = 0; this.#throttled = undefined; }
417
430
  #signal() { const w = this.#wake; this.#wake = undefined; w?.resolve(); }
418
- /** The decoding step: header facts plus the body through the contract, or `mismatch` and dropped. */
431
+ /**
432
+ * The established descriptor's `content_type` (the catalog's, the runtime's statement), set by the
433
+ * follow task from each snapshot: bodies decode by it, not by what this subscription declared.
434
+ */
435
+ contentType;
436
+ /** The decoding step: header facts plus the body by the established `content_type` (msgpack → a value, validated when the contract has a schema; anything else → bytes), or `mismatch` and dropped. */
419
437
  envelope(payload, publisher, dom) {
420
438
  let header, hlen;
421
439
  try {
@@ -427,7 +445,7 @@ export class TopicSubscription {
427
445
  }
428
446
  const body = payload.subarray(hlen);
429
447
  let data;
430
- if (this.contract.raw)
448
+ if ((this.contentType ?? this.contract.contentType) !== MSGPACK)
431
449
  data = body;
432
450
  else {
433
451
  let v;
@@ -448,7 +466,8 @@ export class TopicSubscription {
448
466
  }
449
467
  data = v;
450
468
  }
451
- return { data, tUs: Number(header.tUs), seq: header.seq, publisher, dom, flags: header.flags, type: header.type, tlvs: header.tlvs ?? [] };
469
+ const f = header.flags;
470
+ return { data, tUs: Number(header.tUs), seq: header.seq, publisher, dom, flags: f, type: header.type, tlvs: header.tlvs ?? [], key: (f & FLAGS.KEY) !== 0, config: (f & FLAGS.CONFIG) !== 0, recovery: (f & FLAGS.RECOVERY) !== 0 };
452
471
  }
453
472
  /** One payload taken from `src` (Rust `drain_source`'s step): every-message mode keeps it, latest mode keeps the newest `seq`. */
454
473
  #take(src, payload, t, nowUs_) {
@@ -676,6 +695,8 @@ async function followTopic(sub) {
676
695
  if (session && snapshot) {
677
696
  const entry = snapshot.topics[contract.name];
678
697
  delivery = entry?.desc?.delivery ?? contract.delivery;
698
+ // ... and so is the body's `content_type` (what the read decodes by).
699
+ sub.contentType = entry?.desc?.content_type;
679
700
  const wanted = new Map();
680
701
  if (entry)
681
702
  for (const [prefix, l] of Object.entries(entry.publishers))