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
package/dist/ingest.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming into a nixamp, rather than out of one.
|
|
3
|
+
*
|
|
4
|
+
* A phone or a laptop sends live audio to the server, and the server plays it,
|
|
5
|
+
* serves it to listeners, and broadcasts it onward. Two ways in, because one is
|
|
6
|
+
* not enough:
|
|
7
|
+
*
|
|
8
|
+
* - A single long POST whose body is the stream. ffmpeg, OBS, curl and the
|
|
9
|
+
* desktop app can all do this, and it is the efficient one.
|
|
10
|
+
* - A sequence of small POSTs, for browsers. Chrome only allows a streaming
|
|
11
|
+
* request body over HTTP/2, and a nixamp on your own network is plain
|
|
12
|
+
* HTTP/1.1, so a phone that wants to broadcast has to send chunks.
|
|
13
|
+
*
|
|
14
|
+
* Both end up writing into the same ffmpeg stdin, which is what makes the two
|
|
15
|
+
* paths interchangeable to everything downstream.
|
|
16
|
+
*
|
|
17
|
+
* And a third, which is the one anything native should use: RTMP. Point OBS,
|
|
18
|
+
* Larix or another ffmpeg at rtmp://this-machine/live/<key> and it publishes,
|
|
19
|
+
* with no nixamp-shaped client in the middle. ffmpeg listens for it (-rtmp_listen),
|
|
20
|
+
* so this costs no dependency and no protocol implementation. The HTTP paths
|
|
21
|
+
* above are not a workaround for that: they are what a *browser* has, since a
|
|
22
|
+
* web page cannot speak RTMP at all.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
/** Containers a browser or a desktop encoder actually produces. */
|
|
27
|
+
const FORMATS = new Set(["webm", "ogg", "mp4", "matroska", "mp3", "wav", "flv"]);
|
|
28
|
+
/**
|
|
29
|
+
* ffmpeg's `-f` is a demuxer name, and passing an unknown one is how a stream
|
|
30
|
+
* dies four seconds in with a message nobody sees. An unrecognised container is
|
|
31
|
+
* refused up front instead.
|
|
32
|
+
*/
|
|
33
|
+
export function normaliseFormat(value) {
|
|
34
|
+
if (typeof value !== "string")
|
|
35
|
+
return null;
|
|
36
|
+
const format = value.trim().toLowerCase();
|
|
37
|
+
if (format === "")
|
|
38
|
+
return null;
|
|
39
|
+
// A MediaRecorder mime type: audio/webm;codecs=opus.
|
|
40
|
+
// The subtype may carry a hyphen: audio/x-matroska is what some browsers
|
|
41
|
+
// hand out, and stopping at the hyphen turned it into "x".
|
|
42
|
+
const fromMime = /^(?:audio|video)\/([a-z0-9-]+)/.exec(format)?.[1];
|
|
43
|
+
const candidate = fromMime ?? format;
|
|
44
|
+
const mapped = candidate === "x-matroska" ? "matroska" : candidate;
|
|
45
|
+
return FORMATS.has(mapped) ? mapped : null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The live input.
|
|
49
|
+
*
|
|
50
|
+
* One session at a time: a second sender is refused rather than mixed, because
|
|
51
|
+
* mixing two uninvited streams is never what anybody meant.
|
|
52
|
+
*/
|
|
53
|
+
export class Ingest {
|
|
54
|
+
options;
|
|
55
|
+
child = null;
|
|
56
|
+
session = null;
|
|
57
|
+
closing = false;
|
|
58
|
+
/** The RTMP listener, which outlives any one publisher. */
|
|
59
|
+
rtmpChild = null;
|
|
60
|
+
rtmpPort = null;
|
|
61
|
+
rtmpKey = "";
|
|
62
|
+
constructor(options) {
|
|
63
|
+
this.options = options;
|
|
64
|
+
}
|
|
65
|
+
status() {
|
|
66
|
+
return {
|
|
67
|
+
live: this.session !== null,
|
|
68
|
+
session: this.session,
|
|
69
|
+
rtmp: this.rtmpPort === null ? null : { port: this.rtmpPort, path: `/live/${this.rtmpKey}` },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
get live() {
|
|
73
|
+
return this.session !== null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Open a session. Returns the session, or null when one is already running:
|
|
77
|
+
* the caller answers 409, because "somebody else is already broadcasting" is
|
|
78
|
+
* a different problem from "your request was wrong".
|
|
79
|
+
*/
|
|
80
|
+
open(name, format) {
|
|
81
|
+
if (this.session !== null)
|
|
82
|
+
return null;
|
|
83
|
+
const session = {
|
|
84
|
+
id: randomBytes(8).toString("hex"),
|
|
85
|
+
name: name || "a device",
|
|
86
|
+
format,
|
|
87
|
+
startedAt: Date.now(),
|
|
88
|
+
bytes: 0,
|
|
89
|
+
};
|
|
90
|
+
const [command, ...prefix] = this.options.ffmpeg;
|
|
91
|
+
const child = spawn(command, [
|
|
92
|
+
...prefix,
|
|
93
|
+
"-hide_banner",
|
|
94
|
+
"-loglevel", "error",
|
|
95
|
+
// The demuxer is stated because ffmpeg mis-probes a live, unseekable
|
|
96
|
+
// pipe: it reads a few kilobytes, guesses, and guesses wrong.
|
|
97
|
+
"-f", format,
|
|
98
|
+
"-i", "pipe:0",
|
|
99
|
+
"-vn",
|
|
100
|
+
"-c:a", "libmp3lame",
|
|
101
|
+
"-b:a", "192k",
|
|
102
|
+
"-y",
|
|
103
|
+
"-f", "mp3",
|
|
104
|
+
this.options.sink,
|
|
105
|
+
], { stdio: ["pipe", "ignore", "pipe"] });
|
|
106
|
+
let tail = "";
|
|
107
|
+
child.stderr?.on("data", (chunk) => {
|
|
108
|
+
tail = (tail + chunk.toString()).slice(-2000);
|
|
109
|
+
});
|
|
110
|
+
// A sender that hangs up mid-write breaks the pipe, and an unhandled EPIPE
|
|
111
|
+
// takes the whole server down with it.
|
|
112
|
+
child.stdin?.on("error", () => this.close(""));
|
|
113
|
+
child.on("error", (error) => this.close(error.message));
|
|
114
|
+
child.on("close", (code) => this.close(code === 0 ? "" : tail.trim().split("\n").pop() ?? ""));
|
|
115
|
+
this.child = child;
|
|
116
|
+
this.session = session;
|
|
117
|
+
this.closing = false;
|
|
118
|
+
this.options.onStart(session);
|
|
119
|
+
return session;
|
|
120
|
+
}
|
|
121
|
+
/** Feed it. Returns false once the session is over. */
|
|
122
|
+
write(chunk) {
|
|
123
|
+
const child = this.child;
|
|
124
|
+
const session = this.session;
|
|
125
|
+
if (child === null || session === null || child.stdin === null)
|
|
126
|
+
return false;
|
|
127
|
+
session.bytes += chunk.byteLength;
|
|
128
|
+
return child.stdin.write(chunk);
|
|
129
|
+
}
|
|
130
|
+
/** Pipe a whole request body in, for a sender that can stream one. */
|
|
131
|
+
async pump(body) {
|
|
132
|
+
for await (const chunk of body) {
|
|
133
|
+
if (!this.write(chunk)) {
|
|
134
|
+
// Backpressure: wait for the drain rather than growing a buffer that
|
|
135
|
+
// is really the network's problem.
|
|
136
|
+
await new Promise((done) => this.child?.stdin?.once("drain", done) ?? done(null));
|
|
137
|
+
}
|
|
138
|
+
if (this.session === null)
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Wait for an RTMP publisher, and keep waiting after each one leaves.
|
|
144
|
+
*
|
|
145
|
+
* ffmpeg is the RTMP server here: `-rtmp_listen 1` binds the port and blocks
|
|
146
|
+
* until somebody publishes. It serves one publisher and exits, so the
|
|
147
|
+
* listener is started again afterwards -- otherwise a broadcaster who
|
|
148
|
+
* reconnects finds nothing listening.
|
|
149
|
+
*/
|
|
150
|
+
listenRtmp(port, key) {
|
|
151
|
+
this.rtmpPort = port;
|
|
152
|
+
this.rtmpKey = key;
|
|
153
|
+
this.armRtmp();
|
|
154
|
+
}
|
|
155
|
+
/** Stop waiting for publishers. */
|
|
156
|
+
stopRtmp() {
|
|
157
|
+
this.rtmpPort = null;
|
|
158
|
+
const listener = this.rtmpChild;
|
|
159
|
+
this.rtmpChild = null;
|
|
160
|
+
listener?.kill("SIGKILL");
|
|
161
|
+
}
|
|
162
|
+
armRtmp() {
|
|
163
|
+
const port = this.rtmpPort;
|
|
164
|
+
if (port === null || this.rtmpChild !== null)
|
|
165
|
+
return;
|
|
166
|
+
const [command, ...prefix] = this.options.ffmpeg;
|
|
167
|
+
const child = spawn(command, [
|
|
168
|
+
...prefix,
|
|
169
|
+
"-hide_banner",
|
|
170
|
+
"-loglevel", "error",
|
|
171
|
+
"-rtmp_listen", "1",
|
|
172
|
+
// Wait indefinitely: a stream that starts tomorrow is still the stream.
|
|
173
|
+
"-timeout", "-1",
|
|
174
|
+
"-f", "flv",
|
|
175
|
+
"-i", `rtmp://0.0.0.0:${port}/live/${this.rtmpKey}`,
|
|
176
|
+
"-vn",
|
|
177
|
+
"-c:a", "libmp3lame",
|
|
178
|
+
"-b:a", "192k",
|
|
179
|
+
"-y",
|
|
180
|
+
"-f", "mp3",
|
|
181
|
+
this.options.sink,
|
|
182
|
+
],
|
|
183
|
+
// stdout is watched rather than ignored: the encoded bytes are the only
|
|
184
|
+
// honest signal that a publisher turned up. ffmpeg does not announce a
|
|
185
|
+
// connect, a healthy stream says nothing at -loglevel error, and
|
|
186
|
+
// -progress only reports at the end in this build. Audio existing means
|
|
187
|
+
// audio arrived.
|
|
188
|
+
{ stdio: ["ignore", "pipe", "pipe"] });
|
|
189
|
+
const session = {
|
|
190
|
+
id: randomBytes(8).toString("hex"),
|
|
191
|
+
name: "an RTMP publisher",
|
|
192
|
+
format: "flv",
|
|
193
|
+
startedAt: Date.now(),
|
|
194
|
+
bytes: 0,
|
|
195
|
+
};
|
|
196
|
+
let tail = "";
|
|
197
|
+
child.stderr?.on("data", (chunk) => {
|
|
198
|
+
tail = (tail + chunk.toString()).slice(-2000);
|
|
199
|
+
});
|
|
200
|
+
child.stdout?.on("data", (chunk) => {
|
|
201
|
+
if (this.rtmpChild !== child)
|
|
202
|
+
return;
|
|
203
|
+
if (this.session === null) {
|
|
204
|
+
this.session = session;
|
|
205
|
+
this.options.onStart(session);
|
|
206
|
+
}
|
|
207
|
+
if (this.session === session)
|
|
208
|
+
session.bytes += chunk.byteLength;
|
|
209
|
+
// Consumed and dropped for now. The pipe has to be read either way --
|
|
210
|
+
// an unread one fills and stalls the encoder -- and handing these bytes
|
|
211
|
+
// to the listeners is the next piece of work, not a missing one here.
|
|
212
|
+
this.options.onAudio?.(chunk);
|
|
213
|
+
});
|
|
214
|
+
child.on("error", () => {
|
|
215
|
+
if (this.rtmpChild === child)
|
|
216
|
+
this.rtmpChild = null;
|
|
217
|
+
});
|
|
218
|
+
child.on("close", () => {
|
|
219
|
+
if (this.rtmpChild !== child)
|
|
220
|
+
return;
|
|
221
|
+
this.rtmpChild = null;
|
|
222
|
+
if (this.session === session) {
|
|
223
|
+
this.session = null;
|
|
224
|
+
// A publisher disconnecting is how a broadcast ends, not an error.
|
|
225
|
+
this.options.onEnd(session, "");
|
|
226
|
+
}
|
|
227
|
+
// Listen again for the next one.
|
|
228
|
+
if (this.rtmpPort !== null)
|
|
229
|
+
this.armRtmp();
|
|
230
|
+
});
|
|
231
|
+
this.rtmpChild = child;
|
|
232
|
+
}
|
|
233
|
+
/** End the session, whoever ended it. */
|
|
234
|
+
close(error = "") {
|
|
235
|
+
if (this.closing)
|
|
236
|
+
return;
|
|
237
|
+
this.closing = true;
|
|
238
|
+
const session = this.session;
|
|
239
|
+
const child = this.child;
|
|
240
|
+
this.session = null;
|
|
241
|
+
this.child = null;
|
|
242
|
+
try {
|
|
243
|
+
child?.stdin?.end();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Already broken, which is usually why we are here.
|
|
247
|
+
}
|
|
248
|
+
child?.kill("SIGKILL");
|
|
249
|
+
if (session)
|
|
250
|
+
this.options.onEnd(session, error);
|
|
251
|
+
}
|
|
252
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -47,6 +47,8 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
47
47
|
nixamp serve [source] [options] play here, and hand out a browser remote
|
|
48
48
|
nixamp daemon start|stop|status serve in the background, and let go of it
|
|
49
49
|
nixamp admin [--url U] [--key K] who is connected, and re-stream to them
|
|
50
|
+
nixamp login [--signup] sign in to nixamp.com
|
|
51
|
+
nixamp logout / whoami forget it, or check it
|
|
50
52
|
nixamp update [version] re-run the installer, keeping your choices
|
|
51
53
|
nixamp uninstall [--yes] remove everything the installer created
|
|
52
54
|
|
|
@@ -60,6 +62,15 @@ Options for serve:
|
|
|
60
62
|
--no-media do not stream the library's bytes to remotes
|
|
61
63
|
--no-key serve to anyone who can reach the port, with no share link
|
|
62
64
|
--open-port let the port through the local firewall, and close it on exit
|
|
65
|
+
--publish list it at nixamp.com/directory without asking first
|
|
66
|
+
--no-publish never list it, and do not ask
|
|
67
|
+
--name NAME what to call it in the directory (default: this hostname)
|
|
68
|
+
--ingest accept a live stream in at POST /api/ingest
|
|
69
|
+
--rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
|
|
70
|
+
--rtmp-streams N how many may publish at once (default 3, a port each)
|
|
71
|
+
--rtmp D broadcast out, e.g. --rtmp youtube=<key>. Repeatable
|
|
72
|
+
--x402 charge for listening once more than 5 people are listening
|
|
73
|
+
--no-x402 never charge
|
|
63
74
|
|
|
64
75
|
-v, --version print the version
|
|
65
76
|
--help print this
|
|
@@ -140,6 +151,16 @@ export async function main() {
|
|
|
140
151
|
await admin(rest);
|
|
141
152
|
return;
|
|
142
153
|
}
|
|
154
|
+
if (first === "login" || first === "signup") {
|
|
155
|
+
const { login } = await import("./session.js");
|
|
156
|
+
process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (first === "logout" || first === "whoami") {
|
|
160
|
+
const session = await import("./session.js");
|
|
161
|
+
process.exitCode = first === "logout" ? session.logout() : await session.whoami();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
143
164
|
if (first === "update" || first === "uninstall") {
|
|
144
165
|
const manage = await import("./manage.js");
|
|
145
166
|
process.exitCode = first === "update" ? manage.update(rest) : manage.uninstall(rest);
|
package/dist/manage.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
12
13
|
const SITE = "https://nixamp.com";
|
|
13
14
|
/** Windows has its own installer, its own shim and its own removal script. */
|
|
14
15
|
const windows = process.platform === "win32";
|
|
@@ -17,7 +18,7 @@ const windows = process.platform === "win32";
|
|
|
17
18
|
* wrote, which is the only thing that knows for certain; the walk up from this
|
|
18
19
|
* file covers a shim from an older install that did not set it.
|
|
19
20
|
*/
|
|
20
|
-
export function installRoot(from = new URL(".", import.meta.url)
|
|
21
|
+
export function installRoot(from = fileURLToPath(new URL(".", import.meta.url))) {
|
|
21
22
|
const declared = process.env["NIXAMP_HOME"];
|
|
22
23
|
if (declared && existsSync(join(declared, "manifest.json")))
|
|
23
24
|
return declared;
|
package/dist/owner.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may administer this server.
|
|
3
|
+
*
|
|
4
|
+
* Two ways to be allowed, and they answer different questions:
|
|
5
|
+
*
|
|
6
|
+
* - You hold the control key. That is possession of the share link the server
|
|
7
|
+
* printed, which means you are at the machine or someone at it told you.
|
|
8
|
+
* - You are signed in to nixamp.com as the account that owns this server. That
|
|
9
|
+
* is identity, and it works from a phone on the other side of the world.
|
|
10
|
+
*
|
|
11
|
+
* The server cannot check a nixamp.com token itself -- it has no part of that
|
|
12
|
+
* secret, and it should not. So it asks nixamp.com who the token belongs to and
|
|
13
|
+
* compares the answer to the owner it recorded at startup. Delegating identity
|
|
14
|
+
* and keeping authorisation local is what lets a nixamp on a laptop trust an
|
|
15
|
+
* account it has never seen.
|
|
16
|
+
*/
|
|
17
|
+
/** How long an answer from nixamp.com is trusted before asking again. */
|
|
18
|
+
export declare const CACHE_MS = 60000;
|
|
19
|
+
export interface OwnerOptions {
|
|
20
|
+
/** The account id that owns this server, from the CLI session at startup. */
|
|
21
|
+
ownerId: string;
|
|
22
|
+
/** Where to ask about a token. */
|
|
23
|
+
site: string;
|
|
24
|
+
fetcher?: typeof fetch;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}
|
|
27
|
+
export interface AdminCheck {
|
|
28
|
+
/** May this caller administer the server? */
|
|
29
|
+
allowed: boolean;
|
|
30
|
+
/** How they proved it, for the admin view to show. */
|
|
31
|
+
as: "key" | "owner" | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Ask nixamp.com who a token belongs to, and remember the answer briefly.
|
|
35
|
+
*
|
|
36
|
+
* Briefly, because an admin request should not cost a round trip to another
|
|
37
|
+
* host every time, and not for long, because a revoked session should stop
|
|
38
|
+
* working in about a minute rather than whenever the process restarts.
|
|
39
|
+
*/
|
|
40
|
+
export declare class Owner {
|
|
41
|
+
private readonly options;
|
|
42
|
+
private readonly cache;
|
|
43
|
+
constructor(options: OwnerOptions);
|
|
44
|
+
get claimed(): boolean;
|
|
45
|
+
/** The account a token belongs to, or "" for one nixamp.com does not accept. */
|
|
46
|
+
accountFor(token: string): Promise<string>;
|
|
47
|
+
check(hasControlKey: boolean, token: string): Promise<AdminCheck>;
|
|
48
|
+
/** Forget everything remembered, so a sign-out takes effect at once. */
|
|
49
|
+
forget(): void;
|
|
50
|
+
}
|
|
51
|
+
/** Paths only an administrator may reach. */
|
|
52
|
+
export declare const ADMIN_PATHS: string[];
|
|
53
|
+
export declare function needsAdmin(path: string, method?: string): boolean;
|
package/dist/owner.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may administer this server.
|
|
3
|
+
*
|
|
4
|
+
* Two ways to be allowed, and they answer different questions:
|
|
5
|
+
*
|
|
6
|
+
* - You hold the control key. That is possession of the share link the server
|
|
7
|
+
* printed, which means you are at the machine or someone at it told you.
|
|
8
|
+
* - You are signed in to nixamp.com as the account that owns this server. That
|
|
9
|
+
* is identity, and it works from a phone on the other side of the world.
|
|
10
|
+
*
|
|
11
|
+
* The server cannot check a nixamp.com token itself -- it has no part of that
|
|
12
|
+
* secret, and it should not. So it asks nixamp.com who the token belongs to and
|
|
13
|
+
* compares the answer to the owner it recorded at startup. Delegating identity
|
|
14
|
+
* and keeping authorisation local is what lets a nixamp on a laptop trust an
|
|
15
|
+
* account it has never seen.
|
|
16
|
+
*/
|
|
17
|
+
/** How long an answer from nixamp.com is trusted before asking again. */
|
|
18
|
+
export const CACHE_MS = 60_000;
|
|
19
|
+
/**
|
|
20
|
+
* Ask nixamp.com who a token belongs to, and remember the answer briefly.
|
|
21
|
+
*
|
|
22
|
+
* Briefly, because an admin request should not cost a round trip to another
|
|
23
|
+
* host every time, and not for long, because a revoked session should stop
|
|
24
|
+
* working in about a minute rather than whenever the process restarts.
|
|
25
|
+
*/
|
|
26
|
+
export class Owner {
|
|
27
|
+
options;
|
|
28
|
+
cache = new Map();
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
}
|
|
32
|
+
get claimed() {
|
|
33
|
+
return this.options.ownerId !== "";
|
|
34
|
+
}
|
|
35
|
+
/** The account a token belongs to, or "" for one nixamp.com does not accept. */
|
|
36
|
+
async accountFor(token) {
|
|
37
|
+
if (!token)
|
|
38
|
+
return "";
|
|
39
|
+
const now = (this.options.now ?? Date.now)();
|
|
40
|
+
const remembered = this.cache.get(token);
|
|
41
|
+
if (remembered && now - remembered.at < CACHE_MS)
|
|
42
|
+
return remembered.id;
|
|
43
|
+
const send = this.options.fetcher ?? fetch;
|
|
44
|
+
try {
|
|
45
|
+
const answer = await send(`${this.options.site}/api/v1/auth/me`, {
|
|
46
|
+
headers: { authorization: `Bearer ${token}` },
|
|
47
|
+
});
|
|
48
|
+
if (!answer.ok) {
|
|
49
|
+
// Remember the refusal too, or a wrong token costs a round trip on
|
|
50
|
+
// every request it is presented with.
|
|
51
|
+
this.cache.set(token, { id: "", at: now });
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
const body = (await answer.json());
|
|
55
|
+
const id = typeof body.account?.id === "string" ? body.account.id : "";
|
|
56
|
+
this.cache.set(token, { id, at: now });
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// nixamp.com being unreachable must not turn into "everyone is the
|
|
61
|
+
// owner". It turns into "nobody is", and the control key still works.
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async check(hasControlKey, token) {
|
|
66
|
+
if (hasControlKey)
|
|
67
|
+
return { allowed: true, as: "key" };
|
|
68
|
+
if (!this.claimed)
|
|
69
|
+
return { allowed: false, as: null };
|
|
70
|
+
const account = await this.accountFor(token);
|
|
71
|
+
return account !== "" && account === this.options.ownerId
|
|
72
|
+
? { allowed: true, as: "owner" }
|
|
73
|
+
: { allowed: false, as: null };
|
|
74
|
+
}
|
|
75
|
+
/** Forget everything remembered, so a sign-out takes effect at once. */
|
|
76
|
+
forget() {
|
|
77
|
+
this.cache.clear();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Paths only an administrator may reach. */
|
|
81
|
+
export const ADMIN_PATHS = [
|
|
82
|
+
"/api/connections",
|
|
83
|
+
"/api/source",
|
|
84
|
+
"/api/broadcast",
|
|
85
|
+
"/api/ingest",
|
|
86
|
+
"/api/admin",
|
|
87
|
+
];
|
|
88
|
+
export function needsAdmin(path, method = "GET") {
|
|
89
|
+
if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))
|
|
90
|
+
return true;
|
|
91
|
+
// Publishing to a channel, or ending one, is administering the server.
|
|
92
|
+
// Listening to a channel is not: that is what the share link is for.
|
|
93
|
+
if (path.startsWith("/api/channels/") && method !== "GET")
|
|
94
|
+
return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
/** Listeners who get in for nothing. The sixth is the one who pays. */
|
|
3
|
+
export declare const FREE_LISTENERS = 5;
|
|
4
|
+
export interface PaywallConfig {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
/** EVM address that receives the USDC. Without one there is nothing to pay to. */
|
|
7
|
+
payTo: string;
|
|
8
|
+
/** A scoped CoinPay key with payments:create. */
|
|
9
|
+
coinpayKey: string;
|
|
10
|
+
priceCents: number;
|
|
11
|
+
/** What one price buys. 1440 is a day. */
|
|
12
|
+
passMinutes: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_PAYWALL: PaywallConfig;
|
|
15
|
+
/** Only the audio is behind the gate. */
|
|
16
|
+
export declare const GATED: string[];
|
|
17
|
+
export declare function isGated(path: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Whether this request should be charged at all. Configuration first, because
|
|
20
|
+
* a disabled paywall must never answer 402; then the path, then how busy the
|
|
21
|
+
* stream is.
|
|
22
|
+
*/
|
|
23
|
+
export declare function shouldCharge(config: PaywallConfig, path: string, liveListeners: number): boolean;
|
|
24
|
+
export interface PaywallOptions {
|
|
25
|
+
config: () => PaywallConfig;
|
|
26
|
+
/** How many listeners are on the audio routes right now. */
|
|
27
|
+
liveListeners: () => number;
|
|
28
|
+
/**
|
|
29
|
+
* The origin a payer is quoted, which has to be one they can reach. A
|
|
30
|
+
* function because the port is not known until the socket is bound.
|
|
31
|
+
*/
|
|
32
|
+
siteUrl: () => string;
|
|
33
|
+
/** True for the operator's own browser, which never pays to hear its own music. */
|
|
34
|
+
exempt: (request: IncomingMessage) => boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A node http shim over the gateway's Fetch-API handler. Returns true when it
|
|
38
|
+
* answered the request, so the server's own routing can stop.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createPaywall(options: PaywallOptions): (request: IncomingMessage, response: ServerResponse, path: string) => Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* The gateway sells crawl access, and says so: its 402 tells the caller that
|
|
43
|
+
* "payment is required for training crawlers". Someone trying to hear a song
|
|
44
|
+
* is not a crawler, so the sentence is replaced on the way out. The offer
|
|
45
|
+
* itself is untouched -- only the words are ours.
|
|
46
|
+
*/
|
|
47
|
+
export declare function rewrite(answer: Response, siteUrl: string): Promise<Buffer>;
|
|
48
|
+
/**
|
|
49
|
+
* Enough of a Fetch Request for the gateway: it reads the URL, the method and
|
|
50
|
+
* headers. A body would need streaming, and nothing it gates has one.
|
|
51
|
+
*/
|
|
52
|
+
export declare function toRequest(request: IncomingMessage, siteUrl: string): Request;
|
|
53
|
+
/** Read a paywall out of the environment, for an operator who runs one by hand. */
|
|
54
|
+
export declare function paywallFromEnv(env?: NodeJS.ProcessEnv): PaywallConfig;
|
|
55
|
+
/**
|
|
56
|
+
* The directory's answer may carry a configuration, which is how a server is
|
|
57
|
+
* turned on and off from nixamp.com. Anything missing keeps what it had, and
|
|
58
|
+
* a payTo the operator did not set is never invented here.
|
|
59
|
+
*/
|
|
60
|
+
export declare function applyRemoteConfig(current: PaywallConfig, remote: unknown): PaywallConfig;
|
package/dist/paywall.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paying to listen, once a stream is busy.
|
|
3
|
+
*
|
|
4
|
+
* A nixamp serving a handful of friends should cost nothing and ask nothing.
|
|
5
|
+
* Past that it is bandwidth someone is paying for, so the gate opens: over the
|
|
6
|
+
* free listener count, a new listener is answered 402 with an x402 offer and a
|
|
7
|
+
* dollar buys a day.
|
|
8
|
+
*
|
|
9
|
+
* Three things are deliberate. The count is of *live* listeners, so a stream
|
|
10
|
+
* quietens back to free on its own. Only the audio is gated, because a 402 on
|
|
11
|
+
* /api/state would break the page that has to render the offer. And nobody is
|
|
12
|
+
* ever cut off mid-track: the gate is asked once, when a request arrives, and
|
|
13
|
+
* a listener who got in stays in.
|
|
14
|
+
*/
|
|
15
|
+
import { createGateway } from "@profullstack/x402-gateway";
|
|
16
|
+
/** Listeners who get in for nothing. The sixth is the one who pays. */
|
|
17
|
+
export const FREE_LISTENERS = 5;
|
|
18
|
+
export const DEFAULT_PAYWALL = {
|
|
19
|
+
enabled: false,
|
|
20
|
+
payTo: "",
|
|
21
|
+
coinpayKey: "",
|
|
22
|
+
priceCents: 100,
|
|
23
|
+
passMinutes: 1440,
|
|
24
|
+
};
|
|
25
|
+
/** Only the audio is behind the gate. */
|
|
26
|
+
export const GATED = ["/api/stream/", "/api/media/"];
|
|
27
|
+
export function isGated(path) {
|
|
28
|
+
return GATED.some((prefix) => path.startsWith(prefix));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether this request should be charged at all. Configuration first, because
|
|
32
|
+
* a disabled paywall must never answer 402; then the path, then how busy the
|
|
33
|
+
* stream is.
|
|
34
|
+
*/
|
|
35
|
+
export function shouldCharge(config, path, liveListeners) {
|
|
36
|
+
if (!config.enabled || !config.payTo)
|
|
37
|
+
return false;
|
|
38
|
+
if (!isGated(path))
|
|
39
|
+
return false;
|
|
40
|
+
return liveListeners > FREE_LISTENERS;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A node http shim over the gateway's Fetch-API handler. Returns true when it
|
|
44
|
+
* answered the request, so the server's own routing can stop.
|
|
45
|
+
*/
|
|
46
|
+
export function createPaywall(options) {
|
|
47
|
+
let built = null;
|
|
48
|
+
/** Rebuilt when the configuration changes, because it can change at runtime. */
|
|
49
|
+
const gateway = (config, siteUrl) => {
|
|
50
|
+
const key = `${siteUrl} ${JSON.stringify(config)}`;
|
|
51
|
+
if (built?.key === key)
|
|
52
|
+
return built.handle;
|
|
53
|
+
const gate = createGateway({
|
|
54
|
+
siteUrl,
|
|
55
|
+
siteName: "nixamp",
|
|
56
|
+
payTo: config.payTo,
|
|
57
|
+
priceCents: config.priceCents,
|
|
58
|
+
passMinutes: config.passMinutes,
|
|
59
|
+
coinpay: { apiKey: config.coinpayKey },
|
|
60
|
+
header: "x-nixamp-pass",
|
|
61
|
+
path: "/listen/pay",
|
|
62
|
+
// Whether to charge is about how busy this stream is, not about who is
|
|
63
|
+
// asking, so the agent lists play no part.
|
|
64
|
+
isPaidAgent: () => true,
|
|
65
|
+
benefits: ["Listen to this stream for a day, as many tracks as you like."],
|
|
66
|
+
});
|
|
67
|
+
built = { key, handle: (request) => gate.handle(request) };
|
|
68
|
+
return built.handle;
|
|
69
|
+
};
|
|
70
|
+
return async function paywall(request, response, path) {
|
|
71
|
+
const config = options.config();
|
|
72
|
+
// The sales page answers whenever the paywall is configured, so a listener
|
|
73
|
+
// can buy a pass before the stream is busy enough to need one.
|
|
74
|
+
const selling = config.enabled && config.payTo !== "" && path.startsWith("/listen/pay");
|
|
75
|
+
if (!selling && !shouldCharge(config, path, options.liveListeners()))
|
|
76
|
+
return false;
|
|
77
|
+
if (options.exempt(request))
|
|
78
|
+
return false;
|
|
79
|
+
const siteUrl = options.siteUrl();
|
|
80
|
+
const answer = await gateway(config, siteUrl)(toRequest(request, siteUrl));
|
|
81
|
+
if (answer === null)
|
|
82
|
+
return false;
|
|
83
|
+
const body = await rewrite(answer, siteUrl);
|
|
84
|
+
response.writeHead(answer.status, {
|
|
85
|
+
...Object.fromEntries(answer.headers),
|
|
86
|
+
"content-length": String(body.byteLength),
|
|
87
|
+
});
|
|
88
|
+
response.end(body);
|
|
89
|
+
return true;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The gateway sells crawl access, and says so: its 402 tells the caller that
|
|
94
|
+
* "payment is required for training crawlers". Someone trying to hear a song
|
|
95
|
+
* is not a crawler, so the sentence is replaced on the way out. The offer
|
|
96
|
+
* itself is untouched -- only the words are ours.
|
|
97
|
+
*/
|
|
98
|
+
export async function rewrite(answer, siteUrl) {
|
|
99
|
+
const raw = Buffer.from(await answer.arrayBuffer());
|
|
100
|
+
if (answer.status !== 402 || !(answer.headers.get("content-type") ?? "").includes("json"))
|
|
101
|
+
return raw;
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(raw.toString("utf8"));
|
|
104
|
+
parsed["error"] =
|
|
105
|
+
`This stream has more than ${FREE_LISTENERS} people listening. ` +
|
|
106
|
+
`A pass is ${parsed.pass?.price ?? "$1"} for a day: ${siteUrl}/listen/pay`;
|
|
107
|
+
return Buffer.from(JSON.stringify(parsed));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Not JSON after all: send exactly what the gateway produced.
|
|
111
|
+
return raw;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Enough of a Fetch Request for the gateway: it reads the URL, the method and
|
|
116
|
+
* headers. A body would need streaming, and nothing it gates has one.
|
|
117
|
+
*/
|
|
118
|
+
export function toRequest(request, siteUrl) {
|
|
119
|
+
const headers = new Headers();
|
|
120
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
121
|
+
if (value === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
124
|
+
}
|
|
125
|
+
return new Request(new URL(request.url ?? "/", siteUrl), {
|
|
126
|
+
method: request.method ?? "GET",
|
|
127
|
+
headers,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Read a paywall out of the environment, for an operator who runs one by hand. */
|
|
131
|
+
export function paywallFromEnv(env = process.env) {
|
|
132
|
+
const price = Number(env["NIXAMP_PRICE_CENTS"]);
|
|
133
|
+
const minutes = Number(env["NIXAMP_PASS_MINUTES"]);
|
|
134
|
+
return {
|
|
135
|
+
enabled: env["NIXAMP_X402"] === "1",
|
|
136
|
+
payTo: env["NIXAMP_PAY_TO"] ?? "",
|
|
137
|
+
coinpayKey: env["COINPAY_X402_KEY"] ?? "",
|
|
138
|
+
priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : DEFAULT_PAYWALL.priceCents,
|
|
139
|
+
passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : DEFAULT_PAYWALL.passMinutes,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The directory's answer may carry a configuration, which is how a server is
|
|
144
|
+
* turned on and off from nixamp.com. Anything missing keeps what it had, and
|
|
145
|
+
* a payTo the operator did not set is never invented here.
|
|
146
|
+
*/
|
|
147
|
+
export function applyRemoteConfig(current, remote) {
|
|
148
|
+
if (typeof remote !== "object" || remote === null)
|
|
149
|
+
return current;
|
|
150
|
+
const record = remote;
|
|
151
|
+
const price = Number(record["priceCents"]);
|
|
152
|
+
const minutes = Number(record["passMinutes"]);
|
|
153
|
+
return {
|
|
154
|
+
enabled: typeof record["enabled"] === "boolean" ? record["enabled"] : current.enabled,
|
|
155
|
+
payTo: typeof record["payTo"] === "string" && record["payTo"] ? record["payTo"] : current.payTo,
|
|
156
|
+
coinpayKey: typeof record["coinpayKey"] === "string" && record["coinpayKey"]
|
|
157
|
+
? record["coinpayKey"]
|
|
158
|
+
: current.coinpayKey,
|
|
159
|
+
priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : current.priceCents,
|
|
160
|
+
passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : current.passMinutes,
|
|
161
|
+
};
|
|
162
|
+
}
|