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.
Files changed (65) hide show
  1. package/README.md +225 -0
  2. package/bin/nixamp.mjs +4 -1
  3. package/dist/accounts.d.ts +54 -0
  4. package/dist/accounts.js +160 -0
  5. package/dist/admin.d.ts +47 -0
  6. package/dist/admin.js +209 -0
  7. package/dist/broadcast.d.ts +96 -0
  8. package/dist/broadcast.js +193 -0
  9. package/dist/channels.d.ts +94 -0
  10. package/dist/channels.js +235 -0
  11. package/dist/connections.d.ts +72 -0
  12. package/dist/connections.js +128 -0
  13. package/dist/daemon.d.ts +39 -0
  14. package/dist/daemon.js +170 -0
  15. package/dist/directory.d.ts +63 -0
  16. package/dist/directory.js +111 -0
  17. package/dist/ingest.d.ts +80 -0
  18. package/dist/ingest.js +252 -0
  19. package/dist/main.js +103 -6
  20. package/dist/manage.js +30 -7
  21. package/dist/owner.d.ts +53 -0
  22. package/dist/owner.js +96 -0
  23. package/dist/paywall.d.ts +60 -0
  24. package/dist/paywall.js +162 -0
  25. package/dist/playlist.d.ts +16 -0
  26. package/dist/playlist.js +57 -2
  27. package/dist/publish.d.ts +36 -0
  28. package/dist/publish.js +90 -0
  29. package/dist/rtmp-in.d.ts +22 -0
  30. package/dist/rtmp-in.js +79 -0
  31. package/dist/server.d.ts +117 -4
  32. package/dist/server.js +923 -24
  33. package/dist/session.d.ts +29 -0
  34. package/dist/session.js +184 -0
  35. package/dist/share.d.ts +74 -0
  36. package/dist/share.js +172 -0
  37. package/dist/sources.d.ts +37 -0
  38. package/dist/sources.js +125 -0
  39. package/package.json +5 -2
  40. package/src/accounts.ts +193 -0
  41. package/src/admin.ts +243 -0
  42. package/src/broadcast.ts +264 -0
  43. package/src/channels.ts +281 -0
  44. package/src/connections.ts +158 -0
  45. package/src/daemon.ts +193 -0
  46. package/src/directory.ts +135 -0
  47. package/src/ingest.ts +297 -0
  48. package/src/main.ts +107 -6
  49. package/src/manage.ts +35 -7
  50. package/src/owner.ts +113 -0
  51. package/src/paywall.ts +198 -0
  52. package/src/playlist.ts +68 -2
  53. package/src/publish.ts +101 -0
  54. package/src/rtmp-in.ts +90 -0
  55. package/src/server.ts +1087 -23
  56. package/src/session.ts +209 -0
  57. package/src/share.ts +193 -0
  58. package/src/sources.ts +136 -0
  59. package/src/types/auth-system.d.ts +77 -0
  60. package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
  61. package/web/dist/assets/index-WYJ6R4uF.js +1 -0
  62. package/web/dist/index.html +37 -6
  63. package/web/dist/install.ps1 +214 -0
  64. package/web/dist/sw.js +3 -3
  65. package/web/dist/assets/index-Dhja5wxB.js +0 -1
@@ -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
+ }
@@ -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
+ }