stream-doctor 0.0.4 → 0.0.6

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,6 +1,7 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
- export declare function bundledBinary(): string | null;
2
+ /** Lifecycle state of a streamer or viewer. */
3
3
  export type Status = "streaming" | "receiving" | "waiting_for_playlist" | "ended" | "failed" | "stopped";
4
+ /** State of the publisher, as reported by the daemon. */
4
5
  export interface StreamerStatus {
5
6
  input: string;
6
7
  rtmp_url: string;
@@ -8,15 +9,18 @@ export interface StreamerStatus {
8
9
  error: string | null;
9
10
  live: boolean;
10
11
  }
12
+ /** Audio/video drift measured by a viewer, in milliseconds. */
11
13
  export interface AvDrift {
12
14
  drift_ms: number | null;
13
15
  frame_duration_ms: number | null;
14
16
  latest_samples: number[];
15
17
  }
18
+ /** Metrics collected by a viewer. */
16
19
  export interface Metrics {
17
20
  av_drift?: AvDrift;
18
21
  error?: string;
19
22
  }
23
+ /** State of a viewer, as reported by the daemon. */
20
24
  export interface ViewerStatus {
21
25
  id: string;
22
26
  hls_url: string;
@@ -24,47 +28,66 @@ export interface ViewerStatus {
24
28
  error: string | null;
25
29
  metrics: Metrics;
26
30
  }
27
- export interface ServerStatus {
31
+ /** State of the whole daemon. */
32
+ export interface DaemonStatus {
28
33
  streamer: StreamerStatus | null;
29
34
  viewers: ViewerStatus[];
30
35
  }
31
- export declare function session({ server, binary, }?: {
32
- 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;
33
39
  binary?: string;
40
+ port?: number;
34
41
  }): Promise<Session>;
42
+ /** Path to the daemon binary bundled for this platform, or null if there is none. */
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,19 +1,39 @@
1
- // Client for the stream_doctor server. `session()` spawns the binary (the one
2
- // bundled for this platform, or `binary`) when no server is listening;
3
- // `session.close()` stops it. The daemon's output goes to `session.logFile`.
4
1
  import { spawn } from "node:child_process";
5
2
  import fs from "node:fs";
6
3
  import { createRequire } from "node:module";
4
+ import net, {} from "node:net";
7
5
  import os from "node:os";
8
6
  import path from "node:path";
9
- const DEFAULT_SERVER = "http://localhost:4040";
7
+ const LOG_FILE = path.join(os.tmpdir(), "stream_doctor.log");
10
8
  const PLATFORM_PACKAGES = {
11
9
  "darwin-arm64": "@stream-doctor/darwin-arm64",
12
10
  "linux-arm64": "@stream-doctor/linux-arm64",
13
11
  "linux-x64": "@stream-doctor/linux-x64",
14
12
  };
15
- // The daemon ships as one package per platform, all optional dependencies of
16
- // this one, so only the matching one is installed.
13
+ const TERMINAL_STATUSES = ["ended", "failed", "stopped"];
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);
22
+ }
23
+ let installDir;
24
+ if (!binary) {
25
+ binary = bundledBinary() ?? undefined;
26
+ if (!binary) {
27
+ throw new Error(`no stream_doctor binary bundled for ${process.platform}-${process.arch}, pass one with \`binary\` or a running daemon with \`daemonUrl\``);
28
+ }
29
+ installDir = path.join(path.dirname(binary), "..", ".burrito");
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);
35
+ }
36
+ /** Path to the daemon binary bundled for this platform, or null if there is none. */
17
37
  export function bundledBinary() {
18
38
  const pkg = PLATFORM_PACKAGES[`${process.platform}-${process.arch}`];
19
39
  if (!pkg)
@@ -25,95 +45,31 @@ export function bundledBinary() {
25
45
  return null;
26
46
  }
27
47
  }
28
- async function api(method, path, body, server) {
29
- let res;
30
- try {
31
- res = await fetch(server + path, {
32
- method,
33
- headers: body ? { "content-type": "application/json" } : undefined,
34
- body: body ? JSON.stringify(body) : undefined,
35
- });
36
- }
37
- catch (e) {
38
- throw new Error(`${method} ${path}: cannot reach ${server} (is the server running?): ${e.message}`);
39
- }
40
- const text = await res.text();
41
- if (!res.ok)
42
- throw new Error(`${method} ${path}: HTTP ${res.status}: ${text}`);
43
- return JSON.parse(text);
44
- }
45
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
46
- const TERMINAL_STATUSES = ["ended", "failed", "stopped"];
47
- export async function session({ server = DEFAULT_SERVER, binary, } = {}) {
48
- try {
49
- await api("GET", "/status", null, server);
50
- return new Session(server, null);
51
- }
52
- catch (e) {
53
- let installDir;
54
- const logFile = path.join(os.tmpdir(), "stream_doctor.log");
55
- if (!binary) {
56
- binary = bundledBinary() ?? undefined;
57
- if (!binary) {
58
- throw new Error(`${e.message}; no stream_doctor binary bundled for ${process.platform}-${process.arch}, pass one with \`binary\``);
59
- }
60
- // Burrito unpacks the payload once per release name + ERTS + app version,
61
- // none of which change between npm releases, so its default cache under
62
- // ~/.local/share would keep serving the previous package's daemon.
63
- installDir = path.join(path.dirname(binary), "..", ".burrito");
64
- }
65
- const child = await spawnServer(binary, server, logFile, installDir);
66
- return new Session(server, child, logFile);
67
- }
68
- }
69
- async function spawnServer(binary, server, logFile, installDir) {
70
- if (!fs.existsSync(binary)) {
71
- throw new Error(`${binary} not found, build it with: MIX_ENV=prod mix release`);
72
- }
73
- const env = {
74
- ...process.env,
75
- // the daemon quits once its stdin (the pipe below) breaks, i.e. when this
76
- // process dies, however it dies
77
- STREAM_DOCTOR_EXIT_ON_STDIN_EOF: "1",
78
- ...(installDir ? { STREAM_DOCTOR_INSTALL_DIR: installDir } : {}),
79
- };
80
- const log = fs.openSync(logFile, "a");
81
- // own process group, so that killing the burrito launcher takes the BEAM with it
82
- const child = spawn(binary, [], { stdio: ["pipe", log, log], detached: true, env });
83
- child.on("exit", () => fs.closeSync(log));
84
- for (const deadline = Date.now() + 120_000; Date.now() < deadline;) {
85
- if (child.exitCode !== null) {
86
- throw new Error(`server exited with ${child.exitCode}, see ${logFile}`);
87
- }
88
- await sleep(1000);
89
- try {
90
- await api("GET", "/status", null, server);
91
- return child;
92
- }
93
- catch { }
94
- }
95
- throw new Error(`server didn't come up in 2 minutes, see ${logFile}`);
96
- }
48
+ /** A connection to the daemon, grouping one publisher and any number of viewers. */
97
49
  class Session {
98
- server;
50
+ daemonUrl;
99
51
  child;
100
52
  logFile;
101
- constructor(server, child, logFile = null) {
102
- this.server = server;
53
+ constructor(daemonUrl, child, logFile = null) {
54
+ this.daemonUrl = daemonUrl;
103
55
  this.child = child;
104
56
  this.logFile = logFile;
105
57
  }
58
+ /** Starts publishing `file` to `rtmpUrl`. */
106
59
  publish(rtmpUrl, { file = "test.mp4" } = {}) {
107
- const ready = api("POST", "/streamer", { input: file, rtmp_url: rtmpUrl }, this.server);
108
- 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);
109
62
  }
63
+ /** Starts a viewer collecting metrics from the HLS playlist at `hlsUrl`. */
110
64
  async watch(hlsUrl) {
111
- const { id } = await api("POST", "/viewers", { hls_url: hlsUrl }, this.server);
112
- 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);
113
67
  }
68
+ /** Current state of the daemon. */
114
69
  status() {
115
- return api("GET", "/status", null, this.server);
70
+ return api("GET", "/status", null, this.daemonUrl);
116
71
  }
72
+ /** Stops the daemon if this session spawned it. */
117
73
  close() {
118
74
  const child = this.child;
119
75
  if (!child || child.exitCode !== null || child.pid === undefined)
@@ -126,22 +82,25 @@ class Session {
126
82
  });
127
83
  }
128
84
  }
85
+ /** The publisher of a session. */
129
86
  class Streamer {
130
- server;
87
+ daemonUrl;
131
88
  ready;
132
- constructor(server, ready) {
133
- this.server = server;
89
+ constructor(daemonUrl, ready) {
90
+ this.daemonUrl = daemonUrl;
134
91
  this.ready = ready;
135
92
  ready.catch(() => { });
136
93
  }
94
+ /** Current state of the publisher. */
137
95
  status() {
138
- return this.ready.then(() => api("GET", "/streamer", null, this.server));
96
+ return this.ready.then(() => api("GET", "/streamer", null, this.daemonUrl));
139
97
  }
98
+ /** Resolves once the stream is live, rejects if it ends or fails first. */
140
99
  async waitUntilLive({ timeoutMs = 60_000, intervalMs = 250, } = {}) {
141
100
  await this.ready;
142
101
  const deadline = Date.now() + timeoutMs;
143
102
  for (;;) {
144
- const streamer = await api("GET", "/streamer", null, this.server);
103
+ const streamer = await api("GET", "/streamer", null, this.daemonUrl);
145
104
  if (streamer.live)
146
105
  return streamer;
147
106
  if (TERMINAL_STATUSES.includes(streamer.status)) {
@@ -152,25 +111,30 @@ class Streamer {
152
111
  await sleep(intervalMs);
153
112
  }
154
113
  }
114
+ /** Stops publishing. */
155
115
  async stop() {
156
116
  await this.ready;
157
- return api("DELETE", "/streamer", null, this.server);
117
+ return api("DELETE", "/streamer", null, this.daemonUrl);
158
118
  }
159
119
  }
120
+ /** A viewer of a session. */
160
121
  class Viewer {
161
- server;
122
+ daemonUrl;
162
123
  id;
163
- constructor(server, id) {
164
- this.server = server;
124
+ constructor(daemonUrl, id) {
125
+ this.daemonUrl = daemonUrl;
165
126
  this.id = id;
166
127
  }
128
+ /** Current state of the viewer. */
167
129
  status() {
168
- return api("GET", `/viewers/${this.id}`, null, this.server);
130
+ return api("GET", `/viewers/${this.id}`, null, this.daemonUrl);
169
131
  }
132
+ /** Metrics collected so far. */
170
133
  async metrics() {
171
134
  const { metrics } = await this.status();
172
135
  return metrics;
173
136
  }
137
+ /** Resolves once the viewer ends, fails or is stopped, calling `onUpdate` with each polled state. */
174
138
  async waitUntilDone({ intervalMs = 1000, onUpdate, } = {}) {
175
139
  for (;;) {
176
140
  const viewer = await this.status();
@@ -180,8 +144,63 @@ class Viewer {
180
144
  await sleep(intervalMs);
181
145
  }
182
146
  }
147
+ /** Stops the viewer and returns its final metrics. */
183
148
  async stop() {
184
- const { metrics } = await api("DELETE", `/viewers/${this.id}`, null, this.server);
149
+ const { metrics } = await api("DELETE", `/viewers/${this.id}`, null, this.daemonUrl);
185
150
  return metrics;
186
151
  }
187
152
  }
153
+ async function spawnDaemon(binary, daemonUrl, port, installDir) {
154
+ if (!fs.existsSync(binary)) {
155
+ throw new Error(`${binary} not found, build it with: MIX_ENV=prod mix release`);
156
+ }
157
+ const env = {
158
+ ...process.env,
159
+ STREAM_DOCTOR_EXIT_ON_STDIN_EOF: "1",
160
+ PORT: String(port),
161
+ ...(installDir ? { STREAM_DOCTOR_INSTALL_DIR: installDir } : {}),
162
+ };
163
+ const log = fs.openSync(LOG_FILE, "a");
164
+ const child = spawn(binary, [], { stdio: ["pipe", log, log], detached: true, env });
165
+ child.on("exit", () => fs.closeSync(log));
166
+ for (const deadline = Date.now() + 120_000; Date.now() < deadline;) {
167
+ if (child.exitCode !== null) {
168
+ throw new Error(`daemon exited with ${child.exitCode}, see ${LOG_FILE}`);
169
+ }
170
+ await sleep(1000);
171
+ try {
172
+ await api("GET", "/status", null, daemonUrl);
173
+ return child;
174
+ }
175
+ catch { }
176
+ }
177
+ throw new Error(`daemon didn't come up in 2 minutes, see ${LOG_FILE}`);
178
+ }
179
+ async function api(method, path, body, daemonUrl) {
180
+ let res;
181
+ try {
182
+ res = await fetch(daemonUrl + path, {
183
+ method,
184
+ headers: body ? { "content-type": "application/json" } : undefined,
185
+ body: body ? JSON.stringify(body) : undefined,
186
+ });
187
+ }
188
+ catch (e) {
189
+ throw new Error(`${method} ${path}: cannot reach ${daemonUrl} (is the daemon running?): ${e.message}`);
190
+ }
191
+ const text = await res.text();
192
+ if (!res.ok)
193
+ throw new Error(`${method} ${path}: HTTP ${res.status}: ${text}`);
194
+ return JSON.parse(text);
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
+ }
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.4",
3
+ "version": "0.0.6",
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.4",
37
- "@stream-doctor/linux-arm64": "0.0.4",
38
- "@stream-doctor/linux-x64": "0.0.4"
36
+ "@stream-doctor/darwin-arm64": "0.0.6",
37
+ "@stream-doctor/linux-arm64": "0.0.6",
38
+ "@stream-doctor/linux-x64": "0.0.6"
39
39
  }
40
40
  }