nixamp 0.1.0 → 0.3.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 +225 -0
- package/bin/nixamp.mjs +4 -1
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -0
- package/dist/admin.d.ts +47 -0
- package/dist/admin.js +209 -0
- package/dist/broadcast.d.ts +96 -0
- package/dist/broadcast.js +193 -0
- package/dist/channels.d.ts +94 -0
- package/dist/channels.js +235 -0
- package/dist/connections.d.ts +72 -0
- package/dist/connections.js +128 -0
- package/dist/daemon.d.ts +39 -0
- package/dist/daemon.js +170 -0
- package/dist/directory.d.ts +63 -0
- package/dist/directory.js +111 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +103 -6
- package/dist/manage.js +30 -7
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/playlist.d.ts +16 -0
- package/dist/playlist.js +57 -2
- package/dist/publish.d.ts +36 -0
- package/dist/publish.js +90 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +117 -4
- package/dist/server.js +923 -24
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +74 -0
- package/dist/share.js +172 -0
- package/dist/sources.d.ts +37 -0
- package/dist/sources.js +125 -0
- package/package.json +5 -2
- package/src/accounts.ts +193 -0
- package/src/admin.ts +243 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +158 -0
- package/src/daemon.ts +193 -0
- package/src/directory.ts +135 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +107 -6
- package/src/manage.ts +35 -7
- package/src/owner.ts +113 -0
- package/src/paywall.ts +198 -0
- package/src/playlist.ts +68 -2
- package/src/publish.ts +101 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +1087 -23
- package/src/session.ts +209 -0
- package/src/share.ts +193 -0
- package/src/sources.ts +136 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
- package/web/dist/assets/index-WYJ6R4uF.js +1 -0
- package/web/dist/index.html +37 -6
- package/web/dist/install.ps1 +214 -0
- package/web/dist/sw.js +3 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/dist/channels.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Several streams at once.
|
|
3
|
+
*
|
|
4
|
+
* A channel is one live source and everybody listening to it. Two or three
|
|
5
|
+
* devices can publish at the same time -- a phone, a desktop, a second window
|
|
6
|
+
* -- and each has its own audience, so a listener picks which one to hear.
|
|
7
|
+
*
|
|
8
|
+
* The fan-out is the point. One ffmpeg decodes a publisher's bytes once, and
|
|
9
|
+
* the MP3 it produces is written to every listener attached to that channel.
|
|
10
|
+
* A decode per listener would cost a CPU core each and, for a live stream,
|
|
11
|
+
* would not even agree with itself about what "now" is.
|
|
12
|
+
*
|
|
13
|
+
* A listener joining halfway through gets the stream from that moment, which is
|
|
14
|
+
* what live means. MP3 frames are self-describing, so a player finds the next
|
|
15
|
+
* frame boundary and carries on; there is nothing to catch up on.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
/** A name that can sit in a URL and be read back in a list. */
|
|
20
|
+
export function cleanId(value, fallback = "main") {
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
return fallback;
|
|
23
|
+
const id = value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
24
|
+
return id.slice(0, 40) || fallback;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One live source, and its audience.
|
|
28
|
+
*
|
|
29
|
+
* Everything a listener is sent has been through ffmpeg, so a publisher cannot
|
|
30
|
+
* decide what bytes reach a browser by choosing what to send.
|
|
31
|
+
*/
|
|
32
|
+
export class Channel {
|
|
33
|
+
info;
|
|
34
|
+
options;
|
|
35
|
+
onGone;
|
|
36
|
+
listeners = new Set();
|
|
37
|
+
child = null;
|
|
38
|
+
closing = false;
|
|
39
|
+
constructor(info, options, onGone) {
|
|
40
|
+
this.info = info;
|
|
41
|
+
this.options = options;
|
|
42
|
+
this.onGone = onGone;
|
|
43
|
+
}
|
|
44
|
+
start(format) {
|
|
45
|
+
const [command, ...prefix] = this.options.ffmpeg;
|
|
46
|
+
const child = spawn(command, [
|
|
47
|
+
...prefix,
|
|
48
|
+
"-hide_banner",
|
|
49
|
+
"-loglevel", "error",
|
|
50
|
+
// Stated, because ffmpeg mis-probes a live unseekable pipe: it reads a
|
|
51
|
+
// few kilobytes, guesses, and guesses wrong.
|
|
52
|
+
"-f", format,
|
|
53
|
+
"-i", "pipe:0",
|
|
54
|
+
"-vn",
|
|
55
|
+
"-c:a", "libmp3lame",
|
|
56
|
+
"-b:a", "192k",
|
|
57
|
+
"-f", "mp3",
|
|
58
|
+
"pipe:1",
|
|
59
|
+
], { stdio: ["pipe", "pipe", "pipe"] });
|
|
60
|
+
child.stdout?.on("data", (chunk) => {
|
|
61
|
+
this.info.bytes += chunk.byteLength;
|
|
62
|
+
this.send(chunk);
|
|
63
|
+
});
|
|
64
|
+
// A publisher that hangs up mid-write breaks the pipe, and an unhandled
|
|
65
|
+
// EPIPE takes the whole server with it.
|
|
66
|
+
child.stdin?.on("error", () => this.close());
|
|
67
|
+
child.stdout?.on("error", () => this.close());
|
|
68
|
+
child.on("error", () => this.close());
|
|
69
|
+
child.on("close", () => this.close());
|
|
70
|
+
this.child = child;
|
|
71
|
+
this.options.onStart?.(this.info);
|
|
72
|
+
}
|
|
73
|
+
/** Feed the source. */
|
|
74
|
+
write(chunk) {
|
|
75
|
+
return this.child?.stdin?.write(chunk) ?? false;
|
|
76
|
+
}
|
|
77
|
+
async pump(body) {
|
|
78
|
+
for await (const chunk of body) {
|
|
79
|
+
if (this.closing)
|
|
80
|
+
return;
|
|
81
|
+
if (!this.write(chunk)) {
|
|
82
|
+
await new Promise((done) => this.child?.stdin?.once("drain", done) ?? done(null));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Audio that is already in its final form, from a source we did not spawn.
|
|
88
|
+
* The bytes still only reach a listener after something decoded them; it was
|
|
89
|
+
* simply a different process that did it.
|
|
90
|
+
*/
|
|
91
|
+
feed(chunk) {
|
|
92
|
+
this.info.bytes += chunk.byteLength;
|
|
93
|
+
this.send(chunk);
|
|
94
|
+
}
|
|
95
|
+
/** Write to everyone, and drop anybody whose socket has gone. */
|
|
96
|
+
send(chunk) {
|
|
97
|
+
for (const listener of this.listeners) {
|
|
98
|
+
try {
|
|
99
|
+
listener.write(chunk);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// One listener's broken socket is not the channel's problem.
|
|
103
|
+
this.listeners.delete(listener);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this.info.listeners = this.listeners.size;
|
|
107
|
+
}
|
|
108
|
+
listen(listener) {
|
|
109
|
+
this.listeners.add(listener);
|
|
110
|
+
this.info.listeners = this.listeners.size;
|
|
111
|
+
return () => {
|
|
112
|
+
this.listeners.delete(listener);
|
|
113
|
+
this.info.listeners = this.listeners.size;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
close() {
|
|
117
|
+
if (this.closing)
|
|
118
|
+
return;
|
|
119
|
+
this.closing = true;
|
|
120
|
+
const child = this.child;
|
|
121
|
+
this.child = null;
|
|
122
|
+
try {
|
|
123
|
+
child?.stdin?.end();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Already broken, which is usually why we are here.
|
|
127
|
+
}
|
|
128
|
+
child?.kill("SIGKILL");
|
|
129
|
+
// Listeners are ended rather than left hanging on a stream that stopped.
|
|
130
|
+
for (const listener of this.listeners) {
|
|
131
|
+
try {
|
|
132
|
+
listener.end();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Gone already.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
this.listeners.clear();
|
|
139
|
+
this.info.listeners = 0;
|
|
140
|
+
this.options.onEnd?.(this.info);
|
|
141
|
+
this.onGone(this.info.id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Every channel currently live.
|
|
146
|
+
*
|
|
147
|
+
* A channel exists while somebody is publishing to it and disappears when they
|
|
148
|
+
* stop, so the list is what is actually on rather than what was once
|
|
149
|
+
* configured.
|
|
150
|
+
*/
|
|
151
|
+
export class Channels {
|
|
152
|
+
options;
|
|
153
|
+
open = new Map();
|
|
154
|
+
constructor(options) {
|
|
155
|
+
this.options = options;
|
|
156
|
+
}
|
|
157
|
+
list() {
|
|
158
|
+
return [...this.open.values()]
|
|
159
|
+
.map((channel) => channel.info)
|
|
160
|
+
.sort((a, b) => a.startedAt - b.startedAt);
|
|
161
|
+
}
|
|
162
|
+
get count() {
|
|
163
|
+
return this.open.size;
|
|
164
|
+
}
|
|
165
|
+
/** Total listeners across every channel. */
|
|
166
|
+
get listeners() {
|
|
167
|
+
let total = 0;
|
|
168
|
+
for (const channel of this.open.values())
|
|
169
|
+
total += channel.listeners.size;
|
|
170
|
+
return total;
|
|
171
|
+
}
|
|
172
|
+
has(id) {
|
|
173
|
+
return this.open.has(id);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Claim a channel and start decoding into it. Null when that channel is
|
|
177
|
+
* already being published to: two publishers on one channel would be two
|
|
178
|
+
* songs at once, which is never what anybody meant. Publishing to a
|
|
179
|
+
* *different* channel is exactly what this class exists for.
|
|
180
|
+
*/
|
|
181
|
+
publish(id, name, format, via) {
|
|
182
|
+
if (this.open.has(id))
|
|
183
|
+
return null;
|
|
184
|
+
const channel = new Channel({
|
|
185
|
+
id,
|
|
186
|
+
name: name || "a device",
|
|
187
|
+
format,
|
|
188
|
+
via,
|
|
189
|
+
startedAt: Date.now(),
|
|
190
|
+
bytes: 0,
|
|
191
|
+
listeners: 0,
|
|
192
|
+
}, this.options, (gone) => this.open.delete(gone));
|
|
193
|
+
this.open.set(id, channel);
|
|
194
|
+
channel.start(format);
|
|
195
|
+
return channel;
|
|
196
|
+
}
|
|
197
|
+
/** Attach a listener, or null when nothing is playing on that channel. */
|
|
198
|
+
listen(id, listener) {
|
|
199
|
+
const channel = this.open.get(id);
|
|
200
|
+
return channel ? channel.listen(listener) : null;
|
|
201
|
+
}
|
|
202
|
+
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
203
|
+
writeTo(id, chunk) {
|
|
204
|
+
return this.open.get(id)?.write(chunk) ?? false;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A channel fed by audio somebody else is already decoding.
|
|
208
|
+
*
|
|
209
|
+
* An RTMP listener is an ffmpeg with a publisher on one end, and it produces
|
|
210
|
+
* MP3 on its own. Spawning a second ffmpeg to decode what the first one just
|
|
211
|
+
* decoded would double the work to arrive at the same bytes.
|
|
212
|
+
*/
|
|
213
|
+
attach(id, name, format, via) {
|
|
214
|
+
if (this.open.has(id))
|
|
215
|
+
return null;
|
|
216
|
+
const channel = new Channel({ id, name: name || "a device", format, via, startedAt: Date.now(), bytes: 0, listeners: 0 }, this.options, (gone) => this.open.delete(gone));
|
|
217
|
+
this.open.set(id, channel);
|
|
218
|
+
return channel;
|
|
219
|
+
}
|
|
220
|
+
stop(id) {
|
|
221
|
+
const channel = this.open.get(id);
|
|
222
|
+
if (!channel)
|
|
223
|
+
return false;
|
|
224
|
+
channel.close();
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
stopAll() {
|
|
228
|
+
for (const channel of [...this.open.values()])
|
|
229
|
+
channel.close();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
233
|
+
export function generatedId() {
|
|
234
|
+
return `s${randomBytes(3).toString("hex")}`;
|
|
235
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is listening.
|
|
3
|
+
*
|
|
4
|
+
* A stream is a long-lived request, so the server can say exactly who is
|
|
5
|
+
* connected, to what, since when and how much has gone out. That is the whole
|
|
6
|
+
* point of the admin view: `ss -tn` tells you a socket exists, and nothing
|
|
7
|
+
* about which track is going down it.
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage } from "node:http";
|
|
10
|
+
export type Kind = "stream" | "media" | "events" | "page";
|
|
11
|
+
export interface Connection {
|
|
12
|
+
id: number;
|
|
13
|
+
kind: Kind;
|
|
14
|
+
/** The remote address, with an IPv4-mapped IPv6 prefix taken off. */
|
|
15
|
+
address: string;
|
|
16
|
+
/** Where that address lives: your network, tailscale, or the internet. */
|
|
17
|
+
network: "local" | "private" | "cgnat" | "public";
|
|
18
|
+
/** What is going down it, when we know. */
|
|
19
|
+
track: string;
|
|
20
|
+
/** Whatever the client called itself, trimmed to something printable. */
|
|
21
|
+
agent: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
/**
|
|
24
|
+
* Bytes handed to the socket, which is not the same as bytes the listener
|
|
25
|
+
* has heard: the kernel buffers, and a slow client can be several seconds
|
|
26
|
+
* behind this number. It is the right figure for "is anything going out".
|
|
27
|
+
*/
|
|
28
|
+
bytes: number;
|
|
29
|
+
/** Set when it ends, so the admin view can show what just finished. */
|
|
30
|
+
endedAt: number | null;
|
|
31
|
+
}
|
|
32
|
+
/** ::ffff:10.0.0.1 is 10.0.0.1 wearing a hat. */
|
|
33
|
+
export declare function normaliseAddress(address: string | undefined): string;
|
|
34
|
+
/** Loopback is not "your network"; it is this machine. */
|
|
35
|
+
export declare function networkOf(address: string): Connection["network"];
|
|
36
|
+
/**
|
|
37
|
+
* A user agent, cut to the part that identifies it. Browsers write a paragraph
|
|
38
|
+
* about every engine they have ever pretended to be.
|
|
39
|
+
*/
|
|
40
|
+
export declare function shortAgent(agent: string | undefined): string;
|
|
41
|
+
/**
|
|
42
|
+
* The live set. Finished connections are kept for a while, because "it stopped
|
|
43
|
+
* ten seconds ago" is the most useful thing an admin view can tell you when
|
|
44
|
+
* someone says the stream dropped.
|
|
45
|
+
*/
|
|
46
|
+
export declare class Connections {
|
|
47
|
+
private readonly keep;
|
|
48
|
+
private next;
|
|
49
|
+
private readonly items;
|
|
50
|
+
/** How many finished connections to remember. */
|
|
51
|
+
constructor(keep?: number);
|
|
52
|
+
open(request: IncomingMessage, kind: Kind, track: string): Connection;
|
|
53
|
+
close(id: number): void;
|
|
54
|
+
add(id: number, bytes: number): void;
|
|
55
|
+
/**
|
|
56
|
+
* Live first, oldest connection at the top; then the most recently finished.
|
|
57
|
+
*
|
|
58
|
+
* The id breaks ties because Date.now() has millisecond resolution and ten
|
|
59
|
+
* connections can easily end inside one, which left the order down to
|
|
60
|
+
* whatever the sort happened to do.
|
|
61
|
+
*/
|
|
62
|
+
list(): Connection[];
|
|
63
|
+
/**
|
|
64
|
+
* Live connections that are actually hearing something. The state feed and
|
|
65
|
+
* the page are not listeners, and counting them would put a stream over the
|
|
66
|
+
* free allowance with nobody listening to it.
|
|
67
|
+
*/
|
|
68
|
+
get listening(): number;
|
|
69
|
+
get active(): number;
|
|
70
|
+
/** Drop the oldest finished entries once there are more than we keep. */
|
|
71
|
+
private prune;
|
|
72
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { classify } from "./share.js";
|
|
2
|
+
/** ::ffff:10.0.0.1 is 10.0.0.1 wearing a hat. */
|
|
3
|
+
export function normaliseAddress(address) {
|
|
4
|
+
if (!address)
|
|
5
|
+
return "unknown";
|
|
6
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
7
|
+
return mapped?.[1] ?? address;
|
|
8
|
+
}
|
|
9
|
+
/** Loopback is not "your network"; it is this machine. */
|
|
10
|
+
export function networkOf(address) {
|
|
11
|
+
if (address === "127.0.0.1" || address === "::1" || address === "unknown")
|
|
12
|
+
return "local";
|
|
13
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(address))
|
|
14
|
+
return "public";
|
|
15
|
+
return classify(address);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A user agent, cut to the part that identifies it. Browsers write a paragraph
|
|
19
|
+
* about every engine they have ever pretended to be.
|
|
20
|
+
*/
|
|
21
|
+
export function shortAgent(agent) {
|
|
22
|
+
if (!agent)
|
|
23
|
+
return "—";
|
|
24
|
+
const known = [
|
|
25
|
+
[/\bFirefox\/([\d.]+)/, "Firefox"],
|
|
26
|
+
[/\bEdg\/([\d.]+)/, "Edge"],
|
|
27
|
+
[/\bOPR\/([\d.]+)/, "Opera"],
|
|
28
|
+
[/\bChrome\/([\d.]+)/, "Chrome"],
|
|
29
|
+
[/\bVersion\/([\d.]+).*\bSafari\//, "Safari"],
|
|
30
|
+
[/\bVLC\/([\d.]+)/, "VLC"],
|
|
31
|
+
[/\bcurl\/([\d.]+)/, "curl"],
|
|
32
|
+
[/\bmpv\b/, "mpv"],
|
|
33
|
+
[/\bLavf\/([\d.]+)/, "ffmpeg"],
|
|
34
|
+
];
|
|
35
|
+
for (const [pattern, name] of known) {
|
|
36
|
+
const found = pattern.exec(agent);
|
|
37
|
+
if (found)
|
|
38
|
+
return found[1] ? `${name} ${found[1].split(".")[0]}` : name;
|
|
39
|
+
}
|
|
40
|
+
return agent.slice(0, 24);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The live set. Finished connections are kept for a while, because "it stopped
|
|
44
|
+
* ten seconds ago" is the most useful thing an admin view can tell you when
|
|
45
|
+
* someone says the stream dropped.
|
|
46
|
+
*/
|
|
47
|
+
export class Connections {
|
|
48
|
+
keep;
|
|
49
|
+
next = 1;
|
|
50
|
+
items = new Map();
|
|
51
|
+
/** How many finished connections to remember. */
|
|
52
|
+
constructor(keep = 50) {
|
|
53
|
+
this.keep = keep;
|
|
54
|
+
}
|
|
55
|
+
open(request, kind, track) {
|
|
56
|
+
const address = normaliseAddress(request.socket.remoteAddress);
|
|
57
|
+
const connection = {
|
|
58
|
+
id: this.next++,
|
|
59
|
+
kind,
|
|
60
|
+
address,
|
|
61
|
+
network: networkOf(address),
|
|
62
|
+
track,
|
|
63
|
+
agent: shortAgent(request.headers["user-agent"]),
|
|
64
|
+
startedAt: Date.now(),
|
|
65
|
+
bytes: 0,
|
|
66
|
+
endedAt: null,
|
|
67
|
+
};
|
|
68
|
+
this.items.set(connection.id, connection);
|
|
69
|
+
return connection;
|
|
70
|
+
}
|
|
71
|
+
close(id) {
|
|
72
|
+
const found = this.items.get(id);
|
|
73
|
+
if (!found || found.endedAt !== null)
|
|
74
|
+
return;
|
|
75
|
+
found.endedAt = Date.now();
|
|
76
|
+
this.prune();
|
|
77
|
+
}
|
|
78
|
+
add(id, bytes) {
|
|
79
|
+
const found = this.items.get(id);
|
|
80
|
+
if (found)
|
|
81
|
+
found.bytes += bytes;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Live first, oldest connection at the top; then the most recently finished.
|
|
85
|
+
*
|
|
86
|
+
* The id breaks ties because Date.now() has millisecond resolution and ten
|
|
87
|
+
* connections can easily end inside one, which left the order down to
|
|
88
|
+
* whatever the sort happened to do.
|
|
89
|
+
*/
|
|
90
|
+
list() {
|
|
91
|
+
const all = [...this.items.values()];
|
|
92
|
+
const live = all
|
|
93
|
+
.filter((c) => c.endedAt === null)
|
|
94
|
+
.sort((a, b) => a.startedAt - b.startedAt || a.id - b.id);
|
|
95
|
+
const done = all
|
|
96
|
+
.filter((c) => c.endedAt !== null)
|
|
97
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || b.id - a.id);
|
|
98
|
+
return [...live, ...done];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Live connections that are actually hearing something. The state feed and
|
|
102
|
+
* the page are not listeners, and counting them would put a stream over the
|
|
103
|
+
* free allowance with nobody listening to it.
|
|
104
|
+
*/
|
|
105
|
+
get listening() {
|
|
106
|
+
let count = 0;
|
|
107
|
+
for (const item of this.items.values()) {
|
|
108
|
+
if (item.endedAt === null && (item.kind === "stream" || item.kind === "media"))
|
|
109
|
+
count++;
|
|
110
|
+
}
|
|
111
|
+
return count;
|
|
112
|
+
}
|
|
113
|
+
get active() {
|
|
114
|
+
let count = 0;
|
|
115
|
+
for (const item of this.items.values())
|
|
116
|
+
if (item.endedAt === null)
|
|
117
|
+
count++;
|
|
118
|
+
return count;
|
|
119
|
+
}
|
|
120
|
+
/** Drop the oldest finished entries once there are more than we keep. */
|
|
121
|
+
prune() {
|
|
122
|
+
const done = [...this.items.values()]
|
|
123
|
+
.filter((c) => c.endedAt !== null)
|
|
124
|
+
.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0) || a.id - b.id);
|
|
125
|
+
for (const item of done.slice(0, Math.max(0, done.length - this.keep)))
|
|
126
|
+
this.items.delete(item.id);
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface DaemonState {
|
|
2
|
+
pid: number;
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
/** The share key, so `nixamp admin` can talk to it without being told. */
|
|
6
|
+
key: string | null;
|
|
7
|
+
source: string;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
/** Where its output went, for when it died and you want to know why. */
|
|
10
|
+
log: string;
|
|
11
|
+
}
|
|
12
|
+
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
13
|
+
export declare function stateDir(): string;
|
|
14
|
+
export declare function statePath(): string;
|
|
15
|
+
export declare function logPath(): string;
|
|
16
|
+
export declare function readState(): DaemonState | null;
|
|
17
|
+
export declare function writeState(state: DaemonState): void;
|
|
18
|
+
export declare function clearState(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Signal 0 asks "could I signal this?" without sending anything. A pid file
|
|
21
|
+
* outlives the process that wrote it often enough that trusting one is how you
|
|
22
|
+
* end up reporting a daemon that has been dead since Tuesday.
|
|
23
|
+
*/
|
|
24
|
+
export declare function alive(pid: number): boolean;
|
|
25
|
+
/** The daemon as far as anyone asking is concerned. */
|
|
26
|
+
export declare function status(): {
|
|
27
|
+
running: boolean;
|
|
28
|
+
state: DaemonState | null;
|
|
29
|
+
};
|
|
30
|
+
/** The URL an admin client should talk to. */
|
|
31
|
+
export declare function daemonUrl(state: DaemonState): string;
|
|
32
|
+
/**
|
|
33
|
+
* Start one, detached, and wait until it is actually answering before saying
|
|
34
|
+
* it started. Reporting success and leaving the user to discover a crash in a
|
|
35
|
+
* log file is the thing this is meant to avoid.
|
|
36
|
+
*/
|
|
37
|
+
export declare function start(argv: string[], entry: string): Promise<DaemonState>;
|
|
38
|
+
/** Stop it, and wait for it to actually be gone. */
|
|
39
|
+
export declare function stop(timeoutMs?: number): Promise<boolean>;
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon mode.
|
|
3
|
+
*
|
|
4
|
+
* `nixamp serve` holds a terminal. A server you leave running should not, and
|
|
5
|
+
* you should be able to walk away and come back to it, which means something on
|
|
6
|
+
* disk has to remember where it is and what key it minted. That file is the
|
|
7
|
+
* whole of the daemon: a pid to signal and enough to reconnect.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
14
|
+
export function stateDir() {
|
|
15
|
+
const base = process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
|
|
16
|
+
return join(base, "nixamp");
|
|
17
|
+
}
|
|
18
|
+
export function statePath() {
|
|
19
|
+
return join(stateDir(), "daemon.json");
|
|
20
|
+
}
|
|
21
|
+
export function logPath() {
|
|
22
|
+
return join(stateDir(), "daemon.log");
|
|
23
|
+
}
|
|
24
|
+
export function readState() {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(statePath(), "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function writeState(state) {
|
|
33
|
+
mkdirSync(dirname(statePath()), { recursive: true });
|
|
34
|
+
writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`);
|
|
35
|
+
}
|
|
36
|
+
export function clearState() {
|
|
37
|
+
rmSync(statePath(), { force: true });
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Signal 0 asks "could I signal this?" without sending anything. A pid file
|
|
41
|
+
* outlives the process that wrote it often enough that trusting one is how you
|
|
42
|
+
* end up reporting a daemon that has been dead since Tuesday.
|
|
43
|
+
*/
|
|
44
|
+
export function alive(pid) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// EPERM means it exists and belongs to someone else, which is still alive.
|
|
51
|
+
return error.code === "EPERM";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The daemon as far as anyone asking is concerned. */
|
|
55
|
+
export function status() {
|
|
56
|
+
const state = readState();
|
|
57
|
+
if (state === null)
|
|
58
|
+
return { running: false, state: null };
|
|
59
|
+
return { running: alive(state.pid), state };
|
|
60
|
+
}
|
|
61
|
+
/** The URL an admin client should talk to. */
|
|
62
|
+
export function daemonUrl(state) {
|
|
63
|
+
const host = state.host === "0.0.0.0" || state.host === "::" ? "127.0.0.1" : state.host;
|
|
64
|
+
return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Start one, detached, and wait until it is actually answering before saying
|
|
68
|
+
* it started. Reporting success and leaving the user to discover a crash in a
|
|
69
|
+
* log file is the thing this is meant to avoid.
|
|
70
|
+
*/
|
|
71
|
+
export async function start(argv, entry) {
|
|
72
|
+
const existing = status();
|
|
73
|
+
if (existing.running && existing.state) {
|
|
74
|
+
throw new Error(`nixamp: a daemon is already running (pid ${existing.state.pid}). Stop it first.`);
|
|
75
|
+
}
|
|
76
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
77
|
+
const log = logPath();
|
|
78
|
+
const out = openSync(log, "a");
|
|
79
|
+
const child = spawn(process.execPath, [entry, "serve", ...argv, "--announce"], {
|
|
80
|
+
detached: true,
|
|
81
|
+
stdio: ["ignore", out, out],
|
|
82
|
+
env: { ...process.env, NIXAMP_DAEMON: "1" },
|
|
83
|
+
});
|
|
84
|
+
child.unref();
|
|
85
|
+
if (child.pid === undefined)
|
|
86
|
+
throw new Error("nixamp: could not start the daemon");
|
|
87
|
+
// The server prints one JSON line when it is listening, because guessing how
|
|
88
|
+
// long a start takes is how a flaky `daemon start` is written.
|
|
89
|
+
const announced = await waitForAnnounce(log, 15_000);
|
|
90
|
+
if (announced === null) {
|
|
91
|
+
try {
|
|
92
|
+
process.kill(child.pid, "SIGTERM");
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Already gone, which is the more likely reason we are here.
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`nixamp: the daemon did not start. See ${log}`);
|
|
98
|
+
}
|
|
99
|
+
const state = { ...announced, pid: child.pid, startedAt: Date.now(), log };
|
|
100
|
+
writeState(state);
|
|
101
|
+
return state;
|
|
102
|
+
}
|
|
103
|
+
/** Poll the log for the announce line. */
|
|
104
|
+
async function waitForAnnounce(log, timeoutMs) {
|
|
105
|
+
const deadline = Date.now() + timeoutMs;
|
|
106
|
+
const from = existsSync(log) ? readFileSync(log, "utf8").length : 0;
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
109
|
+
let text;
|
|
110
|
+
try {
|
|
111
|
+
text = readFileSync(log, "utf8").slice(from);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const line of text.split("\n")) {
|
|
117
|
+
if (!line.startsWith("{"))
|
|
118
|
+
continue;
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(line);
|
|
121
|
+
if (parsed["nixamp"] === "listening") {
|
|
122
|
+
return {
|
|
123
|
+
host: String(parsed["host"]),
|
|
124
|
+
port: Number(parsed["port"]),
|
|
125
|
+
key: parsed["key"] ?? null,
|
|
126
|
+
source: String(parsed["source"]),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A partial line: it will be complete on the next pass.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/** Stop it, and wait for it to actually be gone. */
|
|
138
|
+
export async function stop(timeoutMs = 5000) {
|
|
139
|
+
const { running, state } = status();
|
|
140
|
+
if (!state)
|
|
141
|
+
return false;
|
|
142
|
+
if (!running) {
|
|
143
|
+
clearState();
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
process.kill(state.pid, "SIGTERM");
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
clearState();
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const deadline = Date.now() + timeoutMs;
|
|
154
|
+
while (Date.now() < deadline) {
|
|
155
|
+
if (!alive(state.pid)) {
|
|
156
|
+
clearState();
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
160
|
+
}
|
|
161
|
+
// It ignored SIGTERM. ffmpeg children make that more likely than it sounds.
|
|
162
|
+
try {
|
|
163
|
+
process.kill(state.pid, "SIGKILL");
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Gone between the check and the signal.
|
|
167
|
+
}
|
|
168
|
+
clearState();
|
|
169
|
+
return true;
|
|
170
|
+
}
|