stream-doctor 0.0.5 → 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 +2 -2
- package/dist/stream_doctor.d.ts +33 -10
- package/dist/stream_doctor.js +70 -40
- package/package.json +5 -5
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
|
|
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
|
-
[
|
|
11
|
+
[stream-doctor repository](https://github.com/membraneframework/stream-doctor).
|
package/dist/stream_doctor.d.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
+
/** State of the whole daemon. */
|
|
32
|
+
export interface DaemonStatus {
|
|
27
33
|
streamer: StreamerStatus | null;
|
|
28
34
|
viewers: ViewerStatus[];
|
|
29
35
|
}
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
46
|
+
daemonUrl: string;
|
|
37
47
|
child: ChildProcess | null;
|
|
38
48
|
logFile: string | null;
|
|
39
|
-
constructor(
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
daemonUrl: string;
|
|
49
64
|
ready: Promise<StreamerStatus>;
|
|
50
|
-
constructor(
|
|
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
|
-
|
|
78
|
+
daemonUrl: string;
|
|
60
79
|
id: string;
|
|
61
|
-
constructor(
|
|
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 };
|
package/dist/stream_doctor.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
23
|
+
let installDir;
|
|
24
|
+
if (!binary) {
|
|
25
|
+
binary = bundledBinary() ?? undefined;
|
|
21
26
|
if (!binary) {
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
+
daemonUrl;
|
|
45
51
|
child;
|
|
46
52
|
logFile;
|
|
47
|
-
constructor(
|
|
48
|
-
this.
|
|
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.
|
|
54
|
-
return new Streamer(this.
|
|
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.
|
|
58
|
-
return new Viewer(this.
|
|
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.
|
|
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
|
-
|
|
87
|
+
daemonUrl;
|
|
77
88
|
ready;
|
|
78
|
-
constructor(
|
|
79
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
117
|
+
return api("DELETE", "/streamer", null, this.daemonUrl);
|
|
104
118
|
}
|
|
105
119
|
}
|
|
120
|
+
/** A viewer of a session. */
|
|
106
121
|
class Viewer {
|
|
107
|
-
|
|
122
|
+
daemonUrl;
|
|
108
123
|
id;
|
|
109
|
-
constructor(
|
|
110
|
-
this.
|
|
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.
|
|
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.
|
|
149
|
+
const { metrics } = await api("DELETE", `/viewers/${this.id}`, null, this.daemonUrl);
|
|
131
150
|
return metrics;
|
|
132
151
|
}
|
|
133
152
|
}
|
|
134
|
-
async function
|
|
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(`
|
|
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,
|
|
172
|
+
await api("GET", "/status", null, daemonUrl);
|
|
153
173
|
return child;
|
|
154
174
|
}
|
|
155
175
|
catch { }
|
|
156
176
|
}
|
|
157
|
-
throw new Error(`
|
|
177
|
+
throw new Error(`daemon didn't come up in 2 minutes, see ${LOG_FILE}`);
|
|
158
178
|
}
|
|
159
|
-
async function api(method, path, body,
|
|
179
|
+
async function api(method, path, body, daemonUrl) {
|
|
160
180
|
let res;
|
|
161
181
|
try {
|
|
162
|
-
res = await fetch(
|
|
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 ${
|
|
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.
|
|
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-
|
|
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.
|
|
37
|
-
"@stream-doctor/linux-arm64": "0.0.
|
|
38
|
-
"@stream-doctor/linux-x64": "0.0.
|
|
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
|
}
|