stream-doctor 0.0.2

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.
@@ -0,0 +1,69 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ export declare function bundledBinary(): string | null;
3
+ export type Status = "streaming" | "receiving" | "waiting_for_playlist" | "ended" | "failed" | "stopped";
4
+ export interface StreamerStatus {
5
+ input: string;
6
+ rtmp_url: string;
7
+ status: Status;
8
+ error: string | null;
9
+ frames_sent: number;
10
+ }
11
+ export interface AvDrift {
12
+ drift_ms: number | null;
13
+ frame_duration_ms: number | null;
14
+ latest_samples: number[];
15
+ }
16
+ export interface Metrics {
17
+ av_drift?: AvDrift;
18
+ error?: string;
19
+ }
20
+ export interface ViewerStatus {
21
+ id: string;
22
+ hls_url: string;
23
+ status: Status;
24
+ error: string | null;
25
+ metrics: Metrics;
26
+ }
27
+ export interface ServerStatus {
28
+ streamer: StreamerStatus | null;
29
+ viewers: ViewerStatus[];
30
+ }
31
+ export declare function session({ server, binary, }?: {
32
+ server?: string;
33
+ binary?: string;
34
+ }): Promise<Session>;
35
+ declare class Session {
36
+ server: string;
37
+ child: ChildProcess | null;
38
+ constructor(server: string, child: ChildProcess | null);
39
+ publish(rtmpUrl: string, { file }?: {
40
+ file?: string;
41
+ }): Streamer;
42
+ watch(hlsUrl: string): Promise<Viewer>;
43
+ status(): Promise<ServerStatus>;
44
+ close(): Promise<void>;
45
+ }
46
+ declare class Streamer {
47
+ server: string;
48
+ ready: Promise<StreamerStatus>;
49
+ constructor(server: string, ready: Promise<StreamerStatus>);
50
+ status(): Promise<StreamerStatus>;
51
+ waitUntilLive({ timeoutMs, intervalMs, }?: {
52
+ timeoutMs?: number;
53
+ intervalMs?: number;
54
+ }): Promise<StreamerStatus>;
55
+ stop(): Promise<StreamerStatus>;
56
+ }
57
+ declare class Viewer {
58
+ server: string;
59
+ id: string;
60
+ constructor(server: string, id: string);
61
+ status(): Promise<ViewerStatus>;
62
+ metrics(): Promise<Metrics>;
63
+ waitUntilDone({ intervalMs, onUpdate, }?: {
64
+ intervalMs?: number;
65
+ onUpdate?: (viewer: ViewerStatus) => void;
66
+ }): Promise<ViewerStatus>;
67
+ stop(): Promise<Metrics>;
68
+ }
69
+ export type { Session, Streamer, Viewer };
@@ -0,0 +1,165 @@
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
+ import { spawn } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import { createRequire } from "node:module";
7
+ const DEFAULT_SERVER = "http://localhost:4040";
8
+ const PLATFORM_PACKAGES = {
9
+ "darwin-arm64": "@stream-doctor/darwin-arm64",
10
+ "linux-arm64": "@stream-doctor/linux-arm64",
11
+ "linux-x64": "@stream-doctor/linux-x64",
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
+ const TERMINAL_STATUSES = ["ended", "failed", "stopped"];
45
+ export async function session({ server = DEFAULT_SERVER, binary, } = {}) {
46
+ try {
47
+ await api("GET", "/status", null, server);
48
+ return new Session(server, null);
49
+ }
50
+ catch (e) {
51
+ binary ??= bundledBinary() ?? undefined;
52
+ if (!binary) {
53
+ throw new Error(`${e.message}; no stream_doctor binary bundled for ${process.platform}-${process.arch}, pass one with \`binary\``);
54
+ }
55
+ }
56
+ return new Session(server, await spawnServer(binary, server));
57
+ }
58
+ async function spawnServer(binary, server) {
59
+ if (!fs.existsSync(binary)) {
60
+ throw new Error(`${binary} not found, build it with: MIX_ENV=prod mix release`);
61
+ }
62
+ console.log(`starting ${binary}`);
63
+ // own process group, so that killing the burrito launcher takes the BEAM with it
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 { }
74
+ }
75
+ throw new Error("server didn't come up in 2 minutes");
76
+ }
77
+ class Session {
78
+ server;
79
+ child;
80
+ constructor(server, child) {
81
+ this.server = server;
82
+ this.child = child;
83
+ }
84
+ publish(rtmpUrl, { file = "test.mp4" } = {}) {
85
+ const ready = api("POST", "/streamer", { input: file, rtmp_url: rtmpUrl }, this.server);
86
+ return new Streamer(this.server, ready);
87
+ }
88
+ async watch(hlsUrl) {
89
+ const { id } = await api("POST", "/viewers", { hls_url: hlsUrl }, this.server);
90
+ return new Viewer(this.server, id);
91
+ }
92
+ status() {
93
+ return api("GET", "/status", null, this.server);
94
+ }
95
+ close() {
96
+ const child = this.child;
97
+ if (!child || child.exitCode !== null || child.pid === undefined)
98
+ return Promise.resolve();
99
+ const pid = child.pid;
100
+ return new Promise((resolve) => {
101
+ setTimeout(() => process.kill(-pid, "SIGKILL"), 5000).unref();
102
+ child.once("exit", () => resolve());
103
+ process.kill(-pid, "SIGTERM");
104
+ });
105
+ }
106
+ }
107
+ class Streamer {
108
+ server;
109
+ ready;
110
+ constructor(server, ready) {
111
+ this.server = server;
112
+ this.ready = ready;
113
+ ready.catch(() => { });
114
+ }
115
+ status() {
116
+ return this.ready.then(() => api("GET", "/streamer", null, this.server));
117
+ }
118
+ async waitUntilLive({ timeoutMs = 60_000, intervalMs = 250, } = {}) {
119
+ await this.ready;
120
+ const deadline = Date.now() + timeoutMs;
121
+ for (;;) {
122
+ const streamer = await api("GET", "/streamer", null, this.server);
123
+ if (streamer.frames_sent > 0)
124
+ return streamer;
125
+ if (TERMINAL_STATUSES.includes(streamer.status)) {
126
+ throw new Error(`streamer ${streamer.status} before going live${streamer.error ? `: ${streamer.error}` : ""}`);
127
+ }
128
+ if (Date.now() > deadline)
129
+ throw new Error(`streamer not live within ${timeoutMs} ms`);
130
+ await sleep(intervalMs);
131
+ }
132
+ }
133
+ async stop() {
134
+ await this.ready;
135
+ return api("DELETE", "/streamer", null, this.server);
136
+ }
137
+ }
138
+ class Viewer {
139
+ server;
140
+ id;
141
+ constructor(server, id) {
142
+ this.server = server;
143
+ this.id = id;
144
+ }
145
+ status() {
146
+ return api("GET", `/viewers/${this.id}`, null, this.server);
147
+ }
148
+ async metrics() {
149
+ const { metrics } = await this.status();
150
+ return metrics;
151
+ }
152
+ async waitUntilDone({ intervalMs = 1000, onUpdate, } = {}) {
153
+ for (;;) {
154
+ const viewer = await this.status();
155
+ onUpdate?.(viewer);
156
+ if (TERMINAL_STATUSES.includes(viewer.status))
157
+ return viewer;
158
+ await sleep(intervalMs);
159
+ }
160
+ }
161
+ async stop() {
162
+ const { metrics } = await api("DELETE", `/viewers/${this.id}`, null, this.server);
163
+ return metrics;
164
+ }
165
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "stream-doctor",
3
+ "version": "0.0.2",
4
+ "description": "Measures the audio/video drift of a live stream, end to end",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/membraneframework-labs/stream_doctor.git"
9
+ },
10
+ "type": "module",
11
+ "exports": "./dist/stream_doctor.js",
12
+ "types": "./dist/stream_doctor.d.ts",
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.build.json",
21
+ "prepack": "npm run build",
22
+ "test": "node --test src/*.test.ts",
23
+ "lint": "tsc --noEmit && eslint src examples && prettier --check src examples",
24
+ "format": "prettier --write src examples"
25
+ },
26
+ "devDependencies": {
27
+ "@eslint/js": "^9.0.0",
28
+ "@types/node": "^22.20.2",
29
+ "eslint": "^9.0.0",
30
+ "globals": "^16.0.0",
31
+ "prettier": "^3.0.0",
32
+ "typescript": "^6.0.3",
33
+ "typescript-eslint": "^8.70.0"
34
+ },
35
+ "optionalDependencies": {
36
+ "@stream-doctor/darwin-arm64": "0.0.2",
37
+ "@stream-doctor/linux-arm64": "0.0.2",
38
+ "@stream-doctor/linux-x64": "0.0.2"
39
+ }
40
+ }