stream-doctor 0.0.3 → 0.0.5
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 +11 -0
- package/dist/stream_doctor.d.ts +4 -3
- package/dist/stream_doctor.js +66 -55
- package/package.json +7 -7
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# stream-doctor
|
|
2
|
+
|
|
3
|
+
Automated end-to-end testing for video infrastructure: publish a stream into
|
|
4
|
+
your pipeline, watch what comes out the other end and assert on the metrics.
|
|
5
|
+
An early proof of concept, currently a single RTMP to HLS scenario with a
|
|
6
|
+
single metric, the audio/video drift.
|
|
7
|
+
|
|
8
|
+
This is the TypeScript client for the StreamDoctor daemon. It brings the
|
|
9
|
+
daemon binary along, as an optional dependency on `@stream-doctor/<platform>`.
|
|
10
|
+
Setup, usage and the roadmap are described in the
|
|
11
|
+
[StreamDoctor repository](https://github.com/membraneframework-labs/stream_doctor).
|
package/dist/stream_doctor.d.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { type ChildProcess } from "node:child_process";
|
|
2
|
-
export declare function bundledBinary(): string | null;
|
|
3
2
|
export type Status = "streaming" | "receiving" | "waiting_for_playlist" | "ended" | "failed" | "stopped";
|
|
4
3
|
export interface StreamerStatus {
|
|
5
4
|
input: string;
|
|
6
5
|
rtmp_url: string;
|
|
7
6
|
status: Status;
|
|
8
7
|
error: string | null;
|
|
9
|
-
|
|
8
|
+
live: boolean;
|
|
10
9
|
}
|
|
11
10
|
export interface AvDrift {
|
|
12
11
|
drift_ms: number | null;
|
|
@@ -32,10 +31,12 @@ export declare function session({ server, binary, }?: {
|
|
|
32
31
|
server?: string;
|
|
33
32
|
binary?: string;
|
|
34
33
|
}): Promise<Session>;
|
|
34
|
+
export declare function bundledBinary(): string | null;
|
|
35
35
|
declare class Session {
|
|
36
36
|
server: string;
|
|
37
37
|
child: ChildProcess | null;
|
|
38
|
-
|
|
38
|
+
logFile: string | null;
|
|
39
|
+
constructor(server: string, child: ChildProcess | null, logFile?: string | null);
|
|
39
40
|
publish(rtmpUrl: string, { file }?: {
|
|
40
41
|
file?: string;
|
|
41
42
|
}): Streamer;
|
package/dist/stream_doctor.js
CHANGED
|
@@ -1,46 +1,15 @@
|
|
|
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.
|
|
4
1
|
import { spawn } from "node:child_process";
|
|
5
2
|
import fs from "node:fs";
|
|
6
3
|
import { createRequire } from "node:module";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
7
6
|
const DEFAULT_SERVER = "http://localhost:4040";
|
|
7
|
+
const LOG_FILE = path.join(os.tmpdir(), "stream_doctor.log");
|
|
8
8
|
const PLATFORM_PACKAGES = {
|
|
9
9
|
"darwin-arm64": "@stream-doctor/darwin-arm64",
|
|
10
10
|
"linux-arm64": "@stream-doctor/linux-arm64",
|
|
11
11
|
"linux-x64": "@stream-doctor/linux-x64",
|
|
12
12
|
};
|
|
13
|
-
// The daemon ships as one package per platform, all optional dependencies of
|
|
14
|
-
// this one, so only the matching one is installed.
|
|
15
|
-
export function bundledBinary() {
|
|
16
|
-
const pkg = PLATFORM_PACKAGES[`${process.platform}-${process.arch}`];
|
|
17
|
-
if (!pkg)
|
|
18
|
-
return null;
|
|
19
|
-
try {
|
|
20
|
-
return createRequire(import.meta.url).resolve(`${pkg}/bin/stream_doctor`);
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
async function api(method, path, body, server) {
|
|
27
|
-
let res;
|
|
28
|
-
try {
|
|
29
|
-
res = await fetch(server + path, {
|
|
30
|
-
method,
|
|
31
|
-
headers: body ? { "content-type": "application/json" } : undefined,
|
|
32
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
catch (e) {
|
|
36
|
-
throw new Error(`${method} ${path}: cannot reach ${server} (is the server running?): ${e.message}`);
|
|
37
|
-
}
|
|
38
|
-
const text = await res.text();
|
|
39
|
-
if (!res.ok)
|
|
40
|
-
throw new Error(`${method} ${path}: HTTP ${res.status}: ${text}`);
|
|
41
|
-
return JSON.parse(text);
|
|
42
|
-
}
|
|
43
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
13
|
const TERMINAL_STATUSES = ["ended", "failed", "stopped"];
|
|
45
14
|
export async function session({ server = DEFAULT_SERVER, binary, } = {}) {
|
|
46
15
|
try {
|
|
@@ -48,38 +17,37 @@ export async function session({ server = DEFAULT_SERVER, binary, } = {}) {
|
|
|
48
17
|
return new Session(server, null);
|
|
49
18
|
}
|
|
50
19
|
catch (e) {
|
|
51
|
-
|
|
20
|
+
let installDir;
|
|
52
21
|
if (!binary) {
|
|
53
|
-
|
|
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");
|
|
54
27
|
}
|
|
28
|
+
const child = await spawnServer(binary, server, installDir);
|
|
29
|
+
return new Session(server, child, LOG_FILE);
|
|
55
30
|
}
|
|
56
|
-
return new Session(server, await spawnServer(binary, server));
|
|
57
31
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
32
|
+
export function bundledBinary() {
|
|
33
|
+
const pkg = PLATFORM_PACKAGES[`${process.platform}-${process.arch}`];
|
|
34
|
+
if (!pkg)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
return createRequire(import.meta.url).resolve(`${pkg}/bin/stream_doctor`);
|
|
61
38
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const child = spawn(binary, [], { stdio: ["ignore", "inherit", "inherit"], detached: true });
|
|
65
|
-
for (const deadline = Date.now() + 120_000; Date.now() < deadline;) {
|
|
66
|
-
if (child.exitCode !== null)
|
|
67
|
-
throw new Error(`server exited with ${child.exitCode}`);
|
|
68
|
-
await sleep(1000);
|
|
69
|
-
try {
|
|
70
|
-
await api("GET", "/status", null, server);
|
|
71
|
-
return child;
|
|
72
|
-
}
|
|
73
|
-
catch { }
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
74
41
|
}
|
|
75
|
-
throw new Error("server didn't come up in 2 minutes");
|
|
76
42
|
}
|
|
77
43
|
class Session {
|
|
78
44
|
server;
|
|
79
45
|
child;
|
|
80
|
-
|
|
46
|
+
logFile;
|
|
47
|
+
constructor(server, child, logFile = null) {
|
|
81
48
|
this.server = server;
|
|
82
49
|
this.child = child;
|
|
50
|
+
this.logFile = logFile;
|
|
83
51
|
}
|
|
84
52
|
publish(rtmpUrl, { file = "test.mp4" } = {}) {
|
|
85
53
|
const ready = api("POST", "/streamer", { input: file, rtmp_url: rtmpUrl }, this.server);
|
|
@@ -120,7 +88,7 @@ class Streamer {
|
|
|
120
88
|
const deadline = Date.now() + timeoutMs;
|
|
121
89
|
for (;;) {
|
|
122
90
|
const streamer = await api("GET", "/streamer", null, this.server);
|
|
123
|
-
if (streamer.
|
|
91
|
+
if (streamer.live)
|
|
124
92
|
return streamer;
|
|
125
93
|
if (TERMINAL_STATUSES.includes(streamer.status)) {
|
|
126
94
|
throw new Error(`streamer ${streamer.status} before going live${streamer.error ? `: ${streamer.error}` : ""}`);
|
|
@@ -163,3 +131,46 @@ class Viewer {
|
|
|
163
131
|
return metrics;
|
|
164
132
|
}
|
|
165
133
|
}
|
|
134
|
+
async function spawnServer(binary, server, installDir) {
|
|
135
|
+
if (!fs.existsSync(binary)) {
|
|
136
|
+
throw new Error(`${binary} not found, build it with: MIX_ENV=prod mix release`);
|
|
137
|
+
}
|
|
138
|
+
const env = {
|
|
139
|
+
...process.env,
|
|
140
|
+
STREAM_DOCTOR_EXIT_ON_STDIN_EOF: "1",
|
|
141
|
+
...(installDir ? { STREAM_DOCTOR_INSTALL_DIR: installDir } : {}),
|
|
142
|
+
};
|
|
143
|
+
const log = fs.openSync(LOG_FILE, "a");
|
|
144
|
+
const child = spawn(binary, [], { stdio: ["pipe", log, log], detached: true, env });
|
|
145
|
+
child.on("exit", () => fs.closeSync(log));
|
|
146
|
+
for (const deadline = Date.now() + 120_000; Date.now() < deadline;) {
|
|
147
|
+
if (child.exitCode !== null) {
|
|
148
|
+
throw new Error(`server exited with ${child.exitCode}, see ${LOG_FILE}`);
|
|
149
|
+
}
|
|
150
|
+
await sleep(1000);
|
|
151
|
+
try {
|
|
152
|
+
await api("GET", "/status", null, server);
|
|
153
|
+
return child;
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`server didn't come up in 2 minutes, see ${LOG_FILE}`);
|
|
158
|
+
}
|
|
159
|
+
async function api(method, path, body, server) {
|
|
160
|
+
let res;
|
|
161
|
+
try {
|
|
162
|
+
res = await fetch(server + path, {
|
|
163
|
+
method,
|
|
164
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
165
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
throw new Error(`${method} ${path}: cannot reach ${server} (is the server running?): ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
const text = await res.text();
|
|
172
|
+
if (!res.ok)
|
|
173
|
+
throw new Error(`${method} ${path}: HTTP ${res.status}: ${text}`);
|
|
174
|
+
return JSON.parse(text);
|
|
175
|
+
}
|
|
176
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stream-doctor",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.5",
|
|
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",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"build": "tsc -p tsconfig.build.json",
|
|
21
21
|
"prepack": "npm run build",
|
|
22
22
|
"test": "node --test src/*.test.ts",
|
|
23
|
-
"lint": "tsc --noEmit && eslint src
|
|
24
|
-
"format": "prettier --write src
|
|
23
|
+
"lint": "tsc --noEmit && eslint src && prettier --check src",
|
|
24
|
+
"format": "prettier --write src"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@eslint/js": "^9.0.0",
|
|
@@ -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.5",
|
|
37
|
+
"@stream-doctor/linux-arm64": "0.0.5",
|
|
38
|
+
"@stream-doctor/linux-x64": "0.0.5"
|
|
39
39
|
}
|
|
40
40
|
}
|