stream-doctor 0.0.5 → 0.1.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@ your pipeline, watch what comes out the other end and assert on the metrics.
5
5
  An early proof of concept, currently a single RTMP to HLS scenario with a
6
6
  single metric, the audio/video drift.
7
7
 
8
- This is the TypeScript client for the StreamDoctor daemon. It brings the
8
+ This is the TypeScript client for the stream-doctor daemon. It brings the
9
9
  daemon binary along, as an optional dependency on `@stream-doctor/<platform>`.
10
10
  Setup, usage and the roadmap are described in the
11
- [StreamDoctor repository](https://github.com/membraneframework-labs/stream_doctor).
11
+ [stream-doctor repository](https://github.com/membraneframework/stream-doctor).
@@ -1,5 +1,7 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
+ /** Lifecycle state of a streamer or viewer. */
2
3
  export type Status = "streaming" | "receiving" | "waiting_for_playlist" | "ended" | "failed" | "stopped";
4
+ /** State of the publisher, as reported by the daemon. */
3
5
  export interface StreamerStatus {
4
6
  input: string;
5
7
  rtmp_url: string;
@@ -7,15 +9,18 @@ export interface StreamerStatus {
7
9
  error: string | null;
8
10
  live: boolean;
9
11
  }
12
+ /** Audio/video drift measured by a viewer, in milliseconds. */
10
13
  export interface AvDrift {
11
14
  drift_ms: number | null;
12
15
  frame_duration_ms: number | null;
13
16
  latest_samples: number[];
14
17
  }
18
+ /** Metrics collected by a viewer. */
15
19
  export interface Metrics {
16
20
  av_drift?: AvDrift;
17
21
  error?: string;
18
22
  }
23
+ /** State of a viewer, as reported by the daemon. */
19
24
  export interface ViewerStatus {
20
25
  id: string;
21
26
  hls_url: string;
@@ -23,48 +28,66 @@ export interface ViewerStatus {
23
28
  error: string | null;
24
29
  metrics: Metrics;
25
30
  }
26
- export interface ServerStatus {
31
+ /** State of the whole daemon. */
32
+ export interface DaemonStatus {
27
33
  streamer: StreamerStatus | null;
28
34
  viewers: ViewerStatus[];
29
35
  }
30
- export declare function session({ server, binary, }?: {
31
- server?: string;
36
+ /** Spawns a daemon from `binary` (the bundled one by default) on `port` (a free one by default), or connects to a running one when `daemonUrl` is set. */
37
+ export declare function session({ daemonUrl, binary, port, }?: {
38
+ daemonUrl?: string;
32
39
  binary?: string;
40
+ port?: number;
33
41
  }): Promise<Session>;
42
+ /** Path to the daemon binary bundled for this platform, or null if there is none. */
34
43
  export declare function bundledBinary(): string | null;
44
+ /** A connection to the daemon, grouping one publisher and any number of viewers. */
35
45
  declare class Session {
36
- server: string;
46
+ daemonUrl: string;
37
47
  child: ChildProcess | null;
38
48
  logFile: string | null;
39
- constructor(server: string, child: ChildProcess | null, logFile?: string | null);
49
+ constructor(daemonUrl: string, child: ChildProcess | null, logFile?: string | null);
50
+ /** Starts publishing `file` to `rtmpUrl`. */
40
51
  publish(rtmpUrl: string, { file }?: {
41
52
  file?: string;
42
53
  }): Streamer;
54
+ /** Starts a viewer collecting metrics from the HLS playlist at `hlsUrl`. */
43
55
  watch(hlsUrl: string): Promise<Viewer>;
44
- status(): Promise<ServerStatus>;
56
+ /** Current state of the daemon. */
57
+ status(): Promise<DaemonStatus>;
58
+ /** Stops the daemon if this session spawned it. */
45
59
  close(): Promise<void>;
46
60
  }
61
+ /** The publisher of a session. */
47
62
  declare class Streamer {
48
- server: string;
63
+ daemonUrl: string;
49
64
  ready: Promise<StreamerStatus>;
50
- constructor(server: string, ready: Promise<StreamerStatus>);
65
+ constructor(daemonUrl: string, ready: Promise<StreamerStatus>);
66
+ /** Current state of the publisher. */
51
67
  status(): Promise<StreamerStatus>;
68
+ /** Resolves once the stream is live, rejects if it ends or fails first. */
52
69
  waitUntilLive({ timeoutMs, intervalMs, }?: {
53
70
  timeoutMs?: number;
54
71
  intervalMs?: number;
55
72
  }): Promise<StreamerStatus>;
73
+ /** Stops publishing. */
56
74
  stop(): Promise<StreamerStatus>;
57
75
  }
76
+ /** A viewer of a session. */
58
77
  declare class Viewer {
59
- server: string;
78
+ daemonUrl: string;
60
79
  id: string;
61
- constructor(server: string, id: string);
80
+ constructor(daemonUrl: string, id: string);
81
+ /** Current state of the viewer. */
62
82
  status(): Promise<ViewerStatus>;
83
+ /** Metrics collected so far. */
63
84
  metrics(): Promise<Metrics>;
85
+ /** Resolves once the viewer ends, fails or is stopped, calling `onUpdate` with each polled state. */
64
86
  waitUntilDone({ intervalMs, onUpdate, }?: {
65
87
  intervalMs?: number;
66
88
  onUpdate?: (viewer: ViewerStatus) => void;
67
89
  }): Promise<ViewerStatus>;
90
+ /** Stops the viewer and returns its final metrics. */
68
91
  stop(): Promise<Metrics>;
69
92
  }
70
93
  export type { Session, Streamer, Viewer };
@@ -1,9 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
+ import net, {} from "node:net";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
6
- const DEFAULT_SERVER = "http://localhost:4040";
7
7
  const LOG_FILE = path.join(os.tmpdir(), "stream_doctor.log");
8
8
  const PLATFORM_PACKAGES = {
9
9
  "darwin-arm64": "@stream-doctor/darwin-arm64",
@@ -11,24 +11,29 @@ const PLATFORM_PACKAGES = {
11
11
  "linux-x64": "@stream-doctor/linux-x64",
12
12
  };
13
13
  const TERMINAL_STATUSES = ["ended", "failed", "stopped"];
14
- export async function session({ server = DEFAULT_SERVER, binary, } = {}) {
15
- try {
16
- await api("GET", "/status", null, server);
17
- return new Session(server, null);
14
+ /** Spawns a daemon from `binary` (the bundled one by default) on `port` (a free one by default), or connects to a running one when `daemonUrl` is set. */
15
+ export async function session({ daemonUrl, binary, port, } = {}) {
16
+ if (daemonUrl) {
17
+ if (binary !== undefined || port !== undefined) {
18
+ throw new Error("`daemonUrl` connects to a running daemon, it cannot be combined with `binary` or `port`");
19
+ }
20
+ await api("GET", "/status", null, daemonUrl);
21
+ return new Session(daemonUrl, null);
18
22
  }
19
- catch (e) {
20
- let installDir;
23
+ let installDir;
24
+ if (!binary) {
25
+ binary = bundledBinary() ?? undefined;
21
26
  if (!binary) {
22
- binary = bundledBinary() ?? undefined;
23
- if (!binary) {
24
- throw new Error(`${e.message}; no stream_doctor binary bundled for ${process.platform}-${process.arch}, pass one with \`binary\``);
25
- }
26
- installDir = path.join(path.dirname(binary), "..", ".burrito");
27
+ throw new Error(`no stream_doctor binary bundled for ${process.platform}-${process.arch}, pass one with \`binary\` or a running daemon with \`daemonUrl\``);
27
28
  }
28
- const child = await spawnServer(binary, server, installDir);
29
- return new Session(server, child, LOG_FILE);
29
+ installDir = path.join(path.dirname(binary), "..", ".burrito");
30
30
  }
31
+ port ??= await freePort();
32
+ const url = `http://localhost:${port}`;
33
+ const child = await spawnDaemon(binary, url, port, installDir);
34
+ return new Session(url, child, LOG_FILE);
31
35
  }
36
+ /** Path to the daemon binary bundled for this platform, or null if there is none. */
32
37
  export function bundledBinary() {
33
38
  const pkg = PLATFORM_PACKAGES[`${process.platform}-${process.arch}`];
34
39
  if (!pkg)
@@ -40,26 +45,31 @@ export function bundledBinary() {
40
45
  return null;
41
46
  }
42
47
  }
48
+ /** A connection to the daemon, grouping one publisher and any number of viewers. */
43
49
  class Session {
44
- server;
50
+ daemonUrl;
45
51
  child;
46
52
  logFile;
47
- constructor(server, child, logFile = null) {
48
- this.server = server;
53
+ constructor(daemonUrl, child, logFile = null) {
54
+ this.daemonUrl = daemonUrl;
49
55
  this.child = child;
50
56
  this.logFile = logFile;
51
57
  }
58
+ /** Starts publishing `file` to `rtmpUrl`. */
52
59
  publish(rtmpUrl, { file = "test.mp4" } = {}) {
53
- const ready = api("POST", "/streamer", { input: file, rtmp_url: rtmpUrl }, this.server);
54
- return new Streamer(this.server, ready);
60
+ const ready = api("POST", "/streamer", { input: file, rtmp_url: rtmpUrl }, this.daemonUrl);
61
+ return new Streamer(this.daemonUrl, ready);
55
62
  }
63
+ /** Starts a viewer collecting metrics from the HLS playlist at `hlsUrl`. */
56
64
  async watch(hlsUrl) {
57
- const { id } = await api("POST", "/viewers", { hls_url: hlsUrl }, this.server);
58
- return new Viewer(this.server, id);
65
+ const { id } = await api("POST", "/viewers", { hls_url: hlsUrl }, this.daemonUrl);
66
+ return new Viewer(this.daemonUrl, id);
59
67
  }
68
+ /** Current state of the daemon. */
60
69
  status() {
61
- return api("GET", "/status", null, this.server);
70
+ return api("GET", "/status", null, this.daemonUrl);
62
71
  }
72
+ /** Stops the daemon if this session spawned it. */
63
73
  close() {
64
74
  const child = this.child;
65
75
  if (!child || child.exitCode !== null || child.pid === undefined)
@@ -72,22 +82,25 @@ class Session {
72
82
  });
73
83
  }
74
84
  }
85
+ /** The publisher of a session. */
75
86
  class Streamer {
76
- server;
87
+ daemonUrl;
77
88
  ready;
78
- constructor(server, ready) {
79
- this.server = server;
89
+ constructor(daemonUrl, ready) {
90
+ this.daemonUrl = daemonUrl;
80
91
  this.ready = ready;
81
92
  ready.catch(() => { });
82
93
  }
94
+ /** Current state of the publisher. */
83
95
  status() {
84
- return this.ready.then(() => api("GET", "/streamer", null, this.server));
96
+ return this.ready.then(() => api("GET", "/streamer", null, this.daemonUrl));
85
97
  }
98
+ /** Resolves once the stream is live, rejects if it ends or fails first. */
86
99
  async waitUntilLive({ timeoutMs = 60_000, intervalMs = 250, } = {}) {
87
100
  await this.ready;
88
101
  const deadline = Date.now() + timeoutMs;
89
102
  for (;;) {
90
- const streamer = await api("GET", "/streamer", null, this.server);
103
+ const streamer = await api("GET", "/streamer", null, this.daemonUrl);
91
104
  if (streamer.live)
92
105
  return streamer;
93
106
  if (TERMINAL_STATUSES.includes(streamer.status)) {
@@ -98,25 +111,30 @@ class Streamer {
98
111
  await sleep(intervalMs);
99
112
  }
100
113
  }
114
+ /** Stops publishing. */
101
115
  async stop() {
102
116
  await this.ready;
103
- return api("DELETE", "/streamer", null, this.server);
117
+ return api("DELETE", "/streamer", null, this.daemonUrl);
104
118
  }
105
119
  }
120
+ /** A viewer of a session. */
106
121
  class Viewer {
107
- server;
122
+ daemonUrl;
108
123
  id;
109
- constructor(server, id) {
110
- this.server = server;
124
+ constructor(daemonUrl, id) {
125
+ this.daemonUrl = daemonUrl;
111
126
  this.id = id;
112
127
  }
128
+ /** Current state of the viewer. */
113
129
  status() {
114
- return api("GET", `/viewers/${this.id}`, null, this.server);
130
+ return api("GET", `/viewers/${this.id}`, null, this.daemonUrl);
115
131
  }
132
+ /** Metrics collected so far. */
116
133
  async metrics() {
117
134
  const { metrics } = await this.status();
118
135
  return metrics;
119
136
  }
137
+ /** Resolves once the viewer ends, fails or is stopped, calling `onUpdate` with each polled state. */
120
138
  async waitUntilDone({ intervalMs = 1000, onUpdate, } = {}) {
121
139
  for (;;) {
122
140
  const viewer = await this.status();
@@ -126,18 +144,20 @@ class Viewer {
126
144
  await sleep(intervalMs);
127
145
  }
128
146
  }
147
+ /** Stops the viewer and returns its final metrics. */
129
148
  async stop() {
130
- const { metrics } = await api("DELETE", `/viewers/${this.id}`, null, this.server);
149
+ const { metrics } = await api("DELETE", `/viewers/${this.id}`, null, this.daemonUrl);
131
150
  return metrics;
132
151
  }
133
152
  }
134
- async function spawnServer(binary, server, installDir) {
153
+ async function spawnDaemon(binary, daemonUrl, port, installDir) {
135
154
  if (!fs.existsSync(binary)) {
136
155
  throw new Error(`${binary} not found, build it with: MIX_ENV=prod mix release`);
137
156
  }
138
157
  const env = {
139
158
  ...process.env,
140
159
  STREAM_DOCTOR_EXIT_ON_STDIN_EOF: "1",
160
+ PORT: String(port),
141
161
  ...(installDir ? { STREAM_DOCTOR_INSTALL_DIR: installDir } : {}),
142
162
  };
143
163
  const log = fs.openSync(LOG_FILE, "a");
@@ -145,32 +165,42 @@ async function spawnServer(binary, server, installDir) {
145
165
  child.on("exit", () => fs.closeSync(log));
146
166
  for (const deadline = Date.now() + 120_000; Date.now() < deadline;) {
147
167
  if (child.exitCode !== null) {
148
- throw new Error(`server exited with ${child.exitCode}, see ${LOG_FILE}`);
168
+ throw new Error(`daemon exited with ${child.exitCode}, see ${LOG_FILE}`);
149
169
  }
150
170
  await sleep(1000);
151
171
  try {
152
- await api("GET", "/status", null, server);
172
+ await api("GET", "/status", null, daemonUrl);
153
173
  return child;
154
174
  }
155
175
  catch { }
156
176
  }
157
- throw new Error(`server didn't come up in 2 minutes, see ${LOG_FILE}`);
177
+ throw new Error(`daemon didn't come up in 2 minutes, see ${LOG_FILE}`);
158
178
  }
159
- async function api(method, path, body, server) {
179
+ async function api(method, path, body, daemonUrl) {
160
180
  let res;
161
181
  try {
162
- res = await fetch(server + path, {
182
+ res = await fetch(daemonUrl + path, {
163
183
  method,
164
184
  headers: body ? { "content-type": "application/json" } : undefined,
165
185
  body: body ? JSON.stringify(body) : undefined,
166
186
  });
167
187
  }
168
188
  catch (e) {
169
- throw new Error(`${method} ${path}: cannot reach ${server} (is the server running?): ${e.message}`);
189
+ throw new Error(`${method} ${path}: cannot reach ${daemonUrl} (is the daemon running?): ${e.message}`);
170
190
  }
171
191
  const text = await res.text();
172
192
  if (!res.ok)
173
193
  throw new Error(`${method} ${path}: HTTP ${res.status}: ${text}`);
174
194
  return JSON.parse(text);
175
195
  }
196
+ function freePort() {
197
+ return new Promise((resolve, reject) => {
198
+ const probe = net.createServer();
199
+ probe.once("error", reject);
200
+ probe.listen(0, () => {
201
+ const { port } = probe.address();
202
+ probe.close(() => resolve(port));
203
+ });
204
+ });
205
+ }
176
206
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "stream-doctor",
3
- "version": "0.0.5",
3
+ "version": "0.1.0",
4
4
  "description": "Automated end-to-end testing for video infrastructure (early proof of concept)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/membraneframework-labs/stream_doctor.git"
8
+ "url": "git+https://github.com/membraneframework/stream-doctor.git"
9
9
  },
10
10
  "type": "module",
11
11
  "exports": "./dist/stream_doctor.js",
@@ -33,8 +33,8 @@
33
33
  "typescript-eslint": "^8.70.0"
34
34
  },
35
35
  "optionalDependencies": {
36
- "@stream-doctor/darwin-arm64": "0.0.5",
37
- "@stream-doctor/linux-arm64": "0.0.5",
38
- "@stream-doctor/linux-x64": "0.0.5"
36
+ "@stream-doctor/darwin-arm64": "0.1.0",
37
+ "@stream-doctor/linux-arm64": "0.1.0",
38
+ "@stream-doctor/linux-x64": "0.1.0"
39
39
  }
40
40
  }