nixamp 0.2.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 +171 -0
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -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 +6 -0
- package/dist/connections.js +13 -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 +21 -0
- package/dist/manage.js +2 -1
- 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/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 +79 -0
- package/dist/server.js +609 -10
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +16 -0
- package/dist/share.js +19 -0
- package/package.json +5 -2
- package/src/accounts.ts +193 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +13 -0
- package/src/directory.ts +135 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/owner.ts +113 -0
- package/src/paywall.ts +198 -0
- package/src/publish.ts +101 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +702 -10
- package/src/session.ts +209 -0
- package/src/share.ts +27 -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/sw.js +3 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
/** Somewhere for a channel's audio to go. A response, in practice. */
|
|
3
|
+
export interface Listener {
|
|
4
|
+
write(chunk: Buffer): boolean;
|
|
5
|
+
end(): void;
|
|
6
|
+
}
|
|
7
|
+
export interface ChannelInfo {
|
|
8
|
+
id: string;
|
|
9
|
+
/** What the publisher called itself. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** The container it is sending, e.g. webm from a browser, flv over RTMP. */
|
|
12
|
+
format: string;
|
|
13
|
+
/** How it arrived. */
|
|
14
|
+
via: "http" | "rtmp";
|
|
15
|
+
startedAt: number;
|
|
16
|
+
bytes: number;
|
|
17
|
+
listeners: number;
|
|
18
|
+
}
|
|
19
|
+
/** A name that can sit in a URL and be read back in a list. */
|
|
20
|
+
export declare function cleanId(value: unknown, fallback?: string): string;
|
|
21
|
+
export interface ChannelOptions {
|
|
22
|
+
ffmpeg: string[];
|
|
23
|
+
onStart?: (info: ChannelInfo) => void;
|
|
24
|
+
onEnd?: (info: ChannelInfo) => void;
|
|
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 declare class Channel {
|
|
33
|
+
readonly info: ChannelInfo;
|
|
34
|
+
private readonly options;
|
|
35
|
+
private readonly onGone;
|
|
36
|
+
readonly listeners: Set<Listener>;
|
|
37
|
+
private child;
|
|
38
|
+
private closing;
|
|
39
|
+
constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
|
|
40
|
+
start(format: string): void;
|
|
41
|
+
/** Feed the source. */
|
|
42
|
+
write(chunk: Buffer): boolean;
|
|
43
|
+
pump(body: Readable): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Audio that is already in its final form, from a source we did not spawn.
|
|
46
|
+
* The bytes still only reach a listener after something decoded them; it was
|
|
47
|
+
* simply a different process that did it.
|
|
48
|
+
*/
|
|
49
|
+
feed(chunk: Buffer): void;
|
|
50
|
+
/** Write to everyone, and drop anybody whose socket has gone. */
|
|
51
|
+
private send;
|
|
52
|
+
listen(listener: Listener): () => void;
|
|
53
|
+
close(): void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Every channel currently live.
|
|
57
|
+
*
|
|
58
|
+
* A channel exists while somebody is publishing to it and disappears when they
|
|
59
|
+
* stop, so the list is what is actually on rather than what was once
|
|
60
|
+
* configured.
|
|
61
|
+
*/
|
|
62
|
+
export declare class Channels {
|
|
63
|
+
private readonly options;
|
|
64
|
+
private readonly open;
|
|
65
|
+
constructor(options: ChannelOptions);
|
|
66
|
+
list(): ChannelInfo[];
|
|
67
|
+
get count(): number;
|
|
68
|
+
/** Total listeners across every channel. */
|
|
69
|
+
get listeners(): number;
|
|
70
|
+
has(id: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Claim a channel and start decoding into it. Null when that channel is
|
|
73
|
+
* already being published to: two publishers on one channel would be two
|
|
74
|
+
* songs at once, which is never what anybody meant. Publishing to a
|
|
75
|
+
* *different* channel is exactly what this class exists for.
|
|
76
|
+
*/
|
|
77
|
+
publish(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null;
|
|
78
|
+
/** Attach a listener, or null when nothing is playing on that channel. */
|
|
79
|
+
listen(id: string, listener: Listener): (() => void) | null;
|
|
80
|
+
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
81
|
+
writeTo(id: string, chunk: Buffer): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* A channel fed by audio somebody else is already decoding.
|
|
84
|
+
*
|
|
85
|
+
* An RTMP listener is an ffmpeg with a publisher on one end, and it produces
|
|
86
|
+
* MP3 on its own. Spawning a second ffmpeg to decode what the first one just
|
|
87
|
+
* decoded would double the work to arrive at the same bytes.
|
|
88
|
+
*/
|
|
89
|
+
attach(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null;
|
|
90
|
+
stop(id: string): boolean;
|
|
91
|
+
stopAll(): void;
|
|
92
|
+
}
|
|
93
|
+
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
94
|
+
export declare function generatedId(): string;
|
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
|
+
}
|
package/dist/connections.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export declare class Connections {
|
|
|
60
60
|
* whatever the sort happened to do.
|
|
61
61
|
*/
|
|
62
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;
|
|
63
69
|
get active(): number;
|
|
64
70
|
/** Drop the oldest finished entries once there are more than we keep. */
|
|
65
71
|
private prune;
|
package/dist/connections.js
CHANGED
|
@@ -97,6 +97,19 @@ export class Connections {
|
|
|
97
97
|
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || b.id - a.id);
|
|
98
98
|
return [...live, ...done];
|
|
99
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
|
+
}
|
|
100
113
|
get active() {
|
|
101
114
|
let count = 0;
|
|
102
115
|
for (const item of this.items.values())
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public directory.
|
|
3
|
+
*
|
|
4
|
+
* A nixamp that agrees to be listed announces itself to nixamp.com every so
|
|
5
|
+
* often and is forgotten when it stops. There is no database behind it: an
|
|
6
|
+
* entry lives for a few minutes and a heartbeat renews it, so a restart of the
|
|
7
|
+
* directory costs one heartbeat rather than a migration, and a stream that
|
|
8
|
+
* dies falls out of the list without anyone having to notice.
|
|
9
|
+
*
|
|
10
|
+
* What is published is the *listen* link. The control key never leaves the
|
|
11
|
+
* machine it was minted on.
|
|
12
|
+
*/
|
|
13
|
+
/** How long an entry survives without a heartbeat. */
|
|
14
|
+
export declare const TTL_MS: number;
|
|
15
|
+
/** How often a publisher renews. Comfortably inside the TTL. */
|
|
16
|
+
export declare const HEARTBEAT_MS: number;
|
|
17
|
+
export declare const DEFAULT_DIRECTORY = "https://nixamp.com";
|
|
18
|
+
export interface Listing {
|
|
19
|
+
/** Assigned by the directory, so a publisher cannot claim someone else's. */
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
/** The listen link, which is what a browser opens. */
|
|
23
|
+
url: string;
|
|
24
|
+
tracks: number;
|
|
25
|
+
nowPlaying: string;
|
|
26
|
+
/** Set by the directory from the request, never by the publisher. */
|
|
27
|
+
updatedAt: number;
|
|
28
|
+
}
|
|
29
|
+
/** What a publisher sends. Everything else about a listing is ours to decide. */
|
|
30
|
+
export interface Announcement {
|
|
31
|
+
id?: string;
|
|
32
|
+
name: string;
|
|
33
|
+
url: string;
|
|
34
|
+
tracks: number;
|
|
35
|
+
nowPlaying: string;
|
|
36
|
+
}
|
|
37
|
+
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
38
|
+
export declare function clean(value: unknown, max: number): string;
|
|
39
|
+
/**
|
|
40
|
+
* A URL we are willing to list. It has to be somewhere a browser can go, and
|
|
41
|
+
* it must not be a loopback or link-local address: those are only reachable
|
|
42
|
+
* from the machine that published them, so listing one is an entry nobody but
|
|
43
|
+
* the publisher can ever open.
|
|
44
|
+
*/
|
|
45
|
+
export declare function publishable(raw: string): URL | null;
|
|
46
|
+
export declare function parseAnnouncement(input: unknown): Announcement | null;
|
|
47
|
+
/**
|
|
48
|
+
* The registry. In memory on purpose: see the note at the top of the file.
|
|
49
|
+
* One entry per URL, so a publisher restarting does not leave a ghost of
|
|
50
|
+
* itself behind next to the entry that replaced it.
|
|
51
|
+
*/
|
|
52
|
+
export declare class Directory {
|
|
53
|
+
private readonly ttl;
|
|
54
|
+
private readonly now;
|
|
55
|
+
private readonly items;
|
|
56
|
+
private sequence;
|
|
57
|
+
constructor(ttl?: number, now?: () => number);
|
|
58
|
+
announce(announcement: Announcement): Listing;
|
|
59
|
+
withdraw(id: string): void;
|
|
60
|
+
list(): Listing[];
|
|
61
|
+
/** Forget anything that stopped renewing. */
|
|
62
|
+
private sweep;
|
|
63
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public directory.
|
|
3
|
+
*
|
|
4
|
+
* A nixamp that agrees to be listed announces itself to nixamp.com every so
|
|
5
|
+
* often and is forgotten when it stops. There is no database behind it: an
|
|
6
|
+
* entry lives for a few minutes and a heartbeat renews it, so a restart of the
|
|
7
|
+
* directory costs one heartbeat rather than a migration, and a stream that
|
|
8
|
+
* dies falls out of the list without anyone having to notice.
|
|
9
|
+
*
|
|
10
|
+
* What is published is the *listen* link. The control key never leaves the
|
|
11
|
+
* machine it was minted on.
|
|
12
|
+
*/
|
|
13
|
+
/** How long an entry survives without a heartbeat. */
|
|
14
|
+
export const TTL_MS = 4 * 60 * 1000;
|
|
15
|
+
/** How often a publisher renews. Comfortably inside the TTL. */
|
|
16
|
+
export const HEARTBEAT_MS = 90 * 1000;
|
|
17
|
+
export const DEFAULT_DIRECTORY = "https://nixamp.com";
|
|
18
|
+
const MAX_NAME = 60;
|
|
19
|
+
const MAX_TRACK = 120;
|
|
20
|
+
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
21
|
+
export function clean(value, max) {
|
|
22
|
+
if (typeof value !== "string")
|
|
23
|
+
return "";
|
|
24
|
+
// Control characters include the escape that starts an ANSI sequence, and
|
|
25
|
+
// this text is rendered in a terminal as well as a browser.
|
|
26
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A URL we are willing to list. It has to be somewhere a browser can go, and
|
|
30
|
+
* it must not be a loopback or link-local address: those are only reachable
|
|
31
|
+
* from the machine that published them, so listing one is an entry nobody but
|
|
32
|
+
* the publisher can ever open.
|
|
33
|
+
*/
|
|
34
|
+
export function publishable(raw) {
|
|
35
|
+
let url;
|
|
36
|
+
try {
|
|
37
|
+
url = new URL(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
43
|
+
return null;
|
|
44
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
45
|
+
if (host === "localhost" || host === "::1" || host.endsWith(".localhost"))
|
|
46
|
+
return null;
|
|
47
|
+
if (/^127\./.test(host) || /^169\.254\./.test(host))
|
|
48
|
+
return null;
|
|
49
|
+
return url;
|
|
50
|
+
}
|
|
51
|
+
export function parseAnnouncement(input) {
|
|
52
|
+
if (typeof input !== "object" || input === null)
|
|
53
|
+
return null;
|
|
54
|
+
const record = input;
|
|
55
|
+
const url = typeof record["url"] === "string" ? record["url"] : "";
|
|
56
|
+
if (publishable(url) === null)
|
|
57
|
+
return null;
|
|
58
|
+
const name = clean(record["name"], MAX_NAME);
|
|
59
|
+
const tracks = Number(record["tracks"]);
|
|
60
|
+
return {
|
|
61
|
+
...(typeof record["id"] === "string" ? { id: clean(record["id"], 40) } : {}),
|
|
62
|
+
name: name || "a nixamp",
|
|
63
|
+
url,
|
|
64
|
+
tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
|
|
65
|
+
nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The registry. In memory on purpose: see the note at the top of the file.
|
|
70
|
+
* One entry per URL, so a publisher restarting does not leave a ghost of
|
|
71
|
+
* itself behind next to the entry that replaced it.
|
|
72
|
+
*/
|
|
73
|
+
export class Directory {
|
|
74
|
+
ttl;
|
|
75
|
+
now;
|
|
76
|
+
items = new Map();
|
|
77
|
+
sequence = 0;
|
|
78
|
+
constructor(ttl = TTL_MS, now = Date.now) {
|
|
79
|
+
this.ttl = ttl;
|
|
80
|
+
this.now = now;
|
|
81
|
+
}
|
|
82
|
+
announce(announcement) {
|
|
83
|
+
this.sweep();
|
|
84
|
+
const existing = [...this.items.values()].find((item) => item.url === announcement.url);
|
|
85
|
+
const id = existing?.id ?? `s${++this.sequence}${this.now().toString(36)}`;
|
|
86
|
+
const listing = {
|
|
87
|
+
id,
|
|
88
|
+
name: announcement.name,
|
|
89
|
+
url: announcement.url,
|
|
90
|
+
tracks: announcement.tracks,
|
|
91
|
+
nowPlaying: announcement.nowPlaying,
|
|
92
|
+
updatedAt: this.now(),
|
|
93
|
+
};
|
|
94
|
+
this.items.set(id, listing);
|
|
95
|
+
return listing;
|
|
96
|
+
}
|
|
97
|
+
withdraw(id) {
|
|
98
|
+
this.items.delete(id);
|
|
99
|
+
}
|
|
100
|
+
list() {
|
|
101
|
+
this.sweep();
|
|
102
|
+
return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
103
|
+
}
|
|
104
|
+
/** Forget anything that stopped renewing. */
|
|
105
|
+
sweep() {
|
|
106
|
+
const cutoff = this.now() - this.ttl;
|
|
107
|
+
for (const [id, item] of this.items)
|
|
108
|
+
if (item.updatedAt < cutoff)
|
|
109
|
+
this.items.delete(id);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/ingest.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
/** A live source is one session at a time: two would be two songs at once. */
|
|
3
|
+
export interface IngestSession {
|
|
4
|
+
id: string;
|
|
5
|
+
/** What the sender called itself. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** The container the sender is producing, e.g. webm from MediaRecorder. */
|
|
8
|
+
format: string;
|
|
9
|
+
startedAt: number;
|
|
10
|
+
bytes: number;
|
|
11
|
+
}
|
|
12
|
+
export interface IngestStatus {
|
|
13
|
+
live: boolean;
|
|
14
|
+
session: IngestSession | null;
|
|
15
|
+
/** Where a native broadcaster should publish, when one is being listened for. */
|
|
16
|
+
rtmp: {
|
|
17
|
+
port: number;
|
|
18
|
+
path: string;
|
|
19
|
+
} | null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* ffmpeg's `-f` is a demuxer name, and passing an unknown one is how a stream
|
|
23
|
+
* dies four seconds in with a message nobody sees. An unrecognised container is
|
|
24
|
+
* refused up front instead.
|
|
25
|
+
*/
|
|
26
|
+
export declare function normaliseFormat(value: unknown): string | null;
|
|
27
|
+
export interface IngestOptions {
|
|
28
|
+
ffmpeg: string[];
|
|
29
|
+
/** Where the decoded audio should go: a file ffmpeg writes, or a pipe. */
|
|
30
|
+
sink: string;
|
|
31
|
+
/** Called when a session starts, so the player can switch to it. */
|
|
32
|
+
onStart: (session: IngestSession) => void;
|
|
33
|
+
/** Called when it ends, cleanly or otherwise. */
|
|
34
|
+
onEnd: (session: IngestSession, error: string) => void;
|
|
35
|
+
/** Encoded audio, as it arrives from a live publisher. */
|
|
36
|
+
onAudio?: (chunk: Buffer) => void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The live input.
|
|
40
|
+
*
|
|
41
|
+
* One session at a time: a second sender is refused rather than mixed, because
|
|
42
|
+
* mixing two uninvited streams is never what anybody meant.
|
|
43
|
+
*/
|
|
44
|
+
export declare class Ingest {
|
|
45
|
+
private readonly options;
|
|
46
|
+
private child;
|
|
47
|
+
private session;
|
|
48
|
+
private closing;
|
|
49
|
+
/** The RTMP listener, which outlives any one publisher. */
|
|
50
|
+
private rtmpChild;
|
|
51
|
+
private rtmpPort;
|
|
52
|
+
private rtmpKey;
|
|
53
|
+
constructor(options: IngestOptions);
|
|
54
|
+
status(): IngestStatus;
|
|
55
|
+
get live(): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Open a session. Returns the session, or null when one is already running:
|
|
58
|
+
* the caller answers 409, because "somebody else is already broadcasting" is
|
|
59
|
+
* a different problem from "your request was wrong".
|
|
60
|
+
*/
|
|
61
|
+
open(name: string, format: string): IngestSession | null;
|
|
62
|
+
/** Feed it. Returns false once the session is over. */
|
|
63
|
+
write(chunk: Buffer): boolean;
|
|
64
|
+
/** Pipe a whole request body in, for a sender that can stream one. */
|
|
65
|
+
pump(body: Readable): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Wait for an RTMP publisher, and keep waiting after each one leaves.
|
|
68
|
+
*
|
|
69
|
+
* ffmpeg is the RTMP server here: `-rtmp_listen 1` binds the port and blocks
|
|
70
|
+
* until somebody publishes. It serves one publisher and exits, so the
|
|
71
|
+
* listener is started again afterwards -- otherwise a broadcaster who
|
|
72
|
+
* reconnects finds nothing listening.
|
|
73
|
+
*/
|
|
74
|
+
listenRtmp(port: number, key: string): void;
|
|
75
|
+
/** Stop waiting for publishers. */
|
|
76
|
+
stopRtmp(): void;
|
|
77
|
+
private armRtmp;
|
|
78
|
+
/** End the session, whoever ended it. */
|
|
79
|
+
close(error?: string): void;
|
|
80
|
+
}
|