nixamp 0.7.41 → 0.9.3

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 (48) hide show
  1. package/dist/catalogs.d.ts +100 -0
  2. package/dist/catalogs.js +0 -0
  3. package/dist/certs.d.ts +112 -0
  4. package/dist/certs.js +217 -0
  5. package/dist/channels.d.ts +17 -0
  6. package/dist/channels.js +45 -0
  7. package/dist/directory.d.ts +10 -0
  8. package/dist/directory.js +6 -0
  9. package/dist/dns.d.ts +63 -0
  10. package/dist/dns.js +171 -0
  11. package/dist/library.d.ts +36 -0
  12. package/dist/library.js +167 -0
  13. package/dist/main.js +92 -4
  14. package/dist/names.d.ts +66 -0
  15. package/dist/names.js +184 -0
  16. package/dist/naming.d.ts +57 -0
  17. package/dist/naming.js +150 -0
  18. package/dist/owner.js +4 -0
  19. package/dist/publish.d.ts +2 -0
  20. package/dist/publish.js +3 -0
  21. package/dist/server.d.ts +37 -0
  22. package/dist/server.js +418 -5
  23. package/dist/session.d.ts +8 -0
  24. package/dist/session.js +103 -0
  25. package/package.json +8 -2
  26. package/src/catalogs.ts +0 -0
  27. package/src/certs.ts +294 -0
  28. package/src/channels.ts +41 -0
  29. package/src/directory.ts +16 -0
  30. package/src/dns.ts +207 -0
  31. package/src/library.ts +175 -0
  32. package/src/main.ts +88 -4
  33. package/src/names.ts +239 -0
  34. package/src/naming.ts +197 -0
  35. package/src/owner.ts +3 -0
  36. package/src/publish.ts +5 -0
  37. package/src/server.ts +446 -5
  38. package/src/session.ts +117 -0
  39. package/web/dist/assets/{hls-3VKVEQE3-70uzupqn.js → hls-3VKVEQE3-C88rYdXy.js} +1 -1
  40. package/web/dist/assets/index-BB3VT0Ks.js +1 -0
  41. package/web/dist/assets/index-DrXbwjOa.css +1 -0
  42. package/web/dist/assets/{mpegts-DQqgM7pi.js → mpegts-BMDK3Ac9.js} +1 -1
  43. package/web/dist/assets/{mpegts-LO6RVLD6-CzrQKX7m.js → mpegts-LO6RVLD6-C06vXzyy.js} +1 -1
  44. package/web/dist/index.html +23 -2
  45. package/web/dist/install.sh +3 -1
  46. package/web/dist/sw.js +6 -6
  47. package/web/dist/assets/index-ComwKkzf.js +0 -1
  48. package/web/dist/assets/index-D3xGDAOd.css +0 -1
package/dist/session.js CHANGED
@@ -453,6 +453,109 @@ export async function tokens(argv, fetcher = fetch) {
453
453
  return 69;
454
454
  }
455
455
  }
456
+ /**
457
+ * `nixamp dns list|set|rm`: names under your handle, for your servers.
458
+ *
459
+ * The DNS keys stay on nixamp.com; this only says which label should point
460
+ * where. "auto" is the address the request arrives from, which is what a
461
+ * server naming itself wants and what nobody behind a router can type.
462
+ */
463
+ export async function dns(argv, fetcher = fetch) {
464
+ const session = readSession();
465
+ if (session === null) {
466
+ console.error("nixamp: not signed in. Try `nixamp login`.");
467
+ return 1;
468
+ }
469
+ const [command = "list", ...rest] = argv;
470
+ const where = `${session.site}/api/v1/dns`;
471
+ const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
472
+ const flag = (name) => {
473
+ const at = rest.indexOf(name);
474
+ return at === -1 ? undefined : rest[at + 1];
475
+ };
476
+ // "off" is a word a person types; null is what the API takes it to mean.
477
+ const family = (value) => value === undefined ? undefined : value === "off" || value === "none" ? null : value;
478
+ try {
479
+ if (command === "set" || command === "add" || command === "claim") {
480
+ const label = rest.find((a) => !a.startsWith("-")) ?? "";
481
+ if (!label) {
482
+ console.error("nixamp: which name? `nixamp dns set server2` names this machine server2.");
483
+ return 64;
484
+ }
485
+ let a = family(flag("--a"));
486
+ let aaaa = family(flag("--aaaa"));
487
+ // Neither named: a server naming itself, both families from where it is.
488
+ if (a === undefined && aaaa === undefined) {
489
+ a = "auto";
490
+ aaaa = "auto";
491
+ }
492
+ const ttlText = flag("--ttl");
493
+ const ttl = ttlText === undefined ? undefined : Number(ttlText);
494
+ if (ttl !== undefined && (!Number.isInteger(ttl) || ttl < 60)) {
495
+ console.error("nixamp: --ttl is seconds, 60 or more.");
496
+ return 64;
497
+ }
498
+ const answer = await fetcher(`${where}/${encodeURIComponent(label)}`, {
499
+ method: "PUT",
500
+ headers,
501
+ body: JSON.stringify({
502
+ ...(a !== undefined ? { a } : {}),
503
+ ...(aaaa !== undefined ? { aaaa } : {}),
504
+ ...(ttl !== undefined ? { ttl } : {}),
505
+ }),
506
+ });
507
+ const body = (await answer.json().catch(() => ({})));
508
+ if (!answer.ok || !body.name) {
509
+ console.error(`nixamp: ${body.error ?? `could not set it (${answer.status})`}`);
510
+ return 1;
511
+ }
512
+ console.log(`${body.name.host}`);
513
+ console.log(` A ${body.name.a || "—"}`);
514
+ console.log(` AAAA ${body.name.aaaa || "—"}`);
515
+ console.log(` ttl ${body.name.ttl}`);
516
+ return 0;
517
+ }
518
+ if (command === "rm" || command === "remove" || command === "delete") {
519
+ const label = rest.find((a) => !a.startsWith("-")) ?? "";
520
+ if (!label) {
521
+ console.error("nixamp: which name? `nixamp dns list` shows them.");
522
+ return 64;
523
+ }
524
+ const answer = await fetcher(`${where}/${encodeURIComponent(label)}`, { method: "DELETE", headers });
525
+ if (!answer.ok) {
526
+ const body = (await answer.json().catch(() => ({})));
527
+ console.error(`nixamp: ${answer.status === 404 ? "no name like that under your handle" : (body.error ?? "could not remove it")}`);
528
+ return 1;
529
+ }
530
+ console.log(`Removed ${label}.`);
531
+ return 0;
532
+ }
533
+ if (command === "list" || command === "ls") {
534
+ const answer = await fetcher(where, { headers });
535
+ const body = (await answer.json().catch(() => ({})));
536
+ if (!answer.ok) {
537
+ console.error(`nixamp: ${body.error ?? `could not list them (${answer.status})`}`);
538
+ return 1;
539
+ }
540
+ const names = body.names ?? [];
541
+ if (names.length === 0) {
542
+ console.log("No names yet. `nixamp dns set <name>` gives this machine one.");
543
+ return 0;
544
+ }
545
+ const width = Math.max(...names.map((n) => n.host.length));
546
+ for (const name of names) {
547
+ console.log(`${name.host.padEnd(width)} A ${(name.a || "—").padEnd(15)} AAAA ${(name.aaaa || "—").padEnd(39)} ttl ${String(name.ttl).padStart(5)} ${day(name.updatedAt)}`);
548
+ }
549
+ return 0;
550
+ }
551
+ console.error(`nixamp: no such dns command: ${command}`);
552
+ return 64;
553
+ }
554
+ catch (error) {
555
+ console.error(`nixamp: could not reach ${session.site}: ${error.message}`);
556
+ return 69;
557
+ }
558
+ }
456
559
  export function logout() {
457
560
  const session = readSession();
458
561
  clearSession();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.41",
3
+ "version": "0.9.3",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,9 @@
41
41
  "dependencies": {
42
42
  "@profullstack/auth-system": "^0.6.0",
43
43
  "@profullstack/hqtui": "^0.3.0",
44
- "@profullstack/x402-gateway": "^0.4.0",
44
+ "@profullstack/throttle": "0.2.2",
45
+ "@profullstack/x402-gateway": "0.6.0",
46
+ "acme-client": "5.4.0",
45
47
  "pg": "^8.23.0",
46
48
  "web-push": "^3.6.7"
47
49
  },
@@ -67,5 +69,9 @@
67
69
  "repository": {
68
70
  "type": "git",
69
71
  "url": "git+https://github.com/profullstack/nixamp.git"
72
+ },
73
+ "overrides": {
74
+ "https-proxy-agent": "7.0.6",
75
+ "tslib": "2.8.1"
70
76
  }
71
77
  }
Binary file
package/src/certs.ts ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Certificates: one wildcard per handle, issued and renewed by nixamp.com.
3
+ *
4
+ * A server that has been given `server2.chovy.nixamp.com` still answers over
5
+ * plain http until it has a certificate, and getting one by hand is exactly
6
+ * the manual step this exists to remove. So nixamp.com, which already holds
7
+ * the zone, does the whole thing: it orders `*.chovy.nixamp.com` and
8
+ * `chovy.nixamp.com` from Let's Encrypt, answers the DNS-01 challenge by
9
+ * writing the `_acme-challenge` TXT record itself, keeps the result in the
10
+ * database, renews it when it gets close to expiry, and hands cert and key
11
+ * to that account's servers over the authenticated API.
12
+ *
13
+ * One wildcard per handle, not one certificate per server. Let's Encrypt
14
+ * rate-limits per registered domain -- nixamp.com, for all of us -- and every
15
+ * server an account starts is covered by the wildcard it already has.
16
+ *
17
+ * The ACME dance takes minutes, mostly waiting for DNS to propagate, so it is
18
+ * never done inside a request. `forHandle` starts it in the background and
19
+ * answers "issuing"; the server asks again in a while.
20
+ */
21
+ import acme from "acme-client";
22
+ import type { DnsZone } from "./dns.ts";
23
+ import type { Queryable } from "./follows.ts";
24
+
25
+ export interface Issued {
26
+ /** PEM, the full chain. */
27
+ cert: string;
28
+ /** PEM. */
29
+ key: string;
30
+ /** Epoch milliseconds. */
31
+ expiresAt: number;
32
+ }
33
+
34
+ /** Where a DNS-01 challenge gets written, and unwritten. */
35
+ export interface Challenge {
36
+ set(host: string, value: string): Promise<void>;
37
+ clear(host: string, value: string): Promise<void>;
38
+ }
39
+
40
+ /** Something that turns a list of names into a certificate. ACME, in production. */
41
+ export interface Issuer {
42
+ issue(names: string[], challenge: Challenge): Promise<Issued>;
43
+ }
44
+
45
+ export interface AcmeIssuerOptions {
46
+ /** acme.directory.letsencrypt.production, or .staging while trying things out. */
47
+ directoryUrl: string;
48
+ /** The account contact Let's Encrypt writes to about expiry. */
49
+ email: string;
50
+ /** The account's private key, PEM. Kept by the caller; see Certs.accountKey. */
51
+ accountKey: () => Promise<string>;
52
+ /**
53
+ * How long to wait after writing the TXT before letting the CA look for it.
54
+ * A registrar's API answers at once; the world's resolvers do not.
55
+ */
56
+ propagationMs?: number;
57
+ sleep?: (ms: number) => Promise<void>;
58
+ }
59
+
60
+ /** A minute is what Porkbun's 600-second floor tends to cost in practice. */
61
+ export const DEFAULT_PROPAGATION_MS = 60_000;
62
+ /** Renew inside the last thirty days, which is what Let's Encrypt recommends. */
63
+ export const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
64
+ /** A failed order is worth trying again, but not every time somebody asks. */
65
+ export const RETRY_AFTER_MS = 60 * 60 * 1000;
66
+
67
+ const wait = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
68
+
69
+ /**
70
+ * Let's Encrypt, through acme-client, by DNS-01 only: a wildcard cannot be
71
+ * proved any other way, and the zone is the one thing nixamp.com does hold.
72
+ */
73
+ export class AcmeIssuer implements Issuer {
74
+ constructor(private readonly options: AcmeIssuerOptions) {}
75
+
76
+ async issue(names: string[], challenge: Challenge): Promise<Issued> {
77
+ const [first, ...rest] = names;
78
+ if (!first) throw new Error("nothing to issue a certificate for");
79
+ const propagationMs = this.options.propagationMs ?? DEFAULT_PROPAGATION_MS;
80
+ const sleep = this.options.sleep ?? wait;
81
+
82
+ const client = new acme.Client({
83
+ directoryUrl: this.options.directoryUrl,
84
+ accountKey: await this.options.accountKey(),
85
+ });
86
+ // A fresh key per certificate. Reusing the account key for the
87
+ // certificate would make one leak two.
88
+ const [key, csr] = await acme.crypto.createCsr({ commonName: first, altNames: [first, ...rest] });
89
+
90
+ const cert = await client.auto({
91
+ csr,
92
+ email: this.options.email,
93
+ termsOfServiceAgreed: true,
94
+ challengePriority: ["dns-01"],
95
+ // acme-client would otherwise resolve the TXT itself before telling the
96
+ // CA to, and from wherever nixamp.com runs that lookup can lag the CA's.
97
+ skipChallengeVerification: true,
98
+ challengeCreateFn: async (authz, _challenge, keyAuthorization) => {
99
+ await challenge.set(`_acme-challenge.${authz.identifier.value}`, keyAuthorization);
100
+ await sleep(propagationMs);
101
+ },
102
+ challengeRemoveFn: async (authz, _challenge, keyAuthorization) => {
103
+ await challenge.clear(`_acme-challenge.${authz.identifier.value}`, keyAuthorization);
104
+ },
105
+ });
106
+
107
+ const info = acme.crypto.readCertificateInfo(cert);
108
+ return { cert, key: key.toString(), expiresAt: info.notAfter.getTime() };
109
+ }
110
+ }
111
+
112
+ export type CertState =
113
+ | { status: "ready"; cert: string; key: string; expiresAt: number; renewing: boolean }
114
+ | { status: "issuing"; since: number }
115
+ | { status: "failed"; error: string; at: number }
116
+ | { status: "none" };
117
+
118
+ export interface CertsOptions {
119
+ renewBeforeMs?: number;
120
+ retryAfterMs?: number;
121
+ now?: () => number;
122
+ log?: (line: string) => void;
123
+ }
124
+
125
+ const SCHEMA = `
126
+ CREATE TABLE IF NOT EXISTS certs (
127
+ handle TEXT PRIMARY KEY,
128
+ cert TEXT NOT NULL,
129
+ key TEXT NOT NULL,
130
+ expires_at TIMESTAMPTZ NOT NULL,
131
+ issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
132
+ );
133
+ CREATE TABLE IF NOT EXISTS acme_account (
134
+ id INTEGER PRIMARY KEY,
135
+ key_pem TEXT NOT NULL
136
+ );
137
+ `;
138
+
139
+ interface Stored {
140
+ cert: string;
141
+ key: string;
142
+ expiresAt: number;
143
+ }
144
+
145
+ /** What is happening to a handle's certificate right now, in this process. */
146
+ type Work =
147
+ | { kind: "issuing"; since: number; done: Promise<void> }
148
+ | { kind: "failed"; error: string; at: number };
149
+
150
+ /**
151
+ * The certificates, one per handle, and the work of getting them.
152
+ *
153
+ * The rows are the truth and survive a restart; what is in flight lives in
154
+ * memory, because nixamp.com is one process and an order interrupted by a
155
+ * deploy is simply started again when next asked for.
156
+ */
157
+ export class Certs {
158
+ private ready: Promise<void> | null = null;
159
+ private readonly work = new Map<string, Work>();
160
+ private readonly renewBeforeMs: number;
161
+ private readonly retryAfterMs: number;
162
+ private readonly now: () => number;
163
+ private readonly log: (line: string) => void;
164
+
165
+ constructor(
166
+ private readonly db: Queryable,
167
+ private readonly zone: DnsZone,
168
+ private readonly issuer: Issuer,
169
+ options: CertsOptions = {},
170
+ ) {
171
+ this.renewBeforeMs = options.renewBeforeMs ?? RENEW_BEFORE_MS;
172
+ this.retryAfterMs = options.retryAfterMs ?? RETRY_AFTER_MS;
173
+ this.now = options.now ?? Date.now;
174
+ this.log = options.log ?? (() => undefined);
175
+ }
176
+
177
+ private async ensure(): Promise<void> {
178
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
179
+ await this.ready;
180
+ }
181
+
182
+ /**
183
+ * The ACME account key, made once and kept. Losing it would not lose any
184
+ * certificate, but every renewal would register a new account, and Let's
185
+ * Encrypt rate-limits those too.
186
+ */
187
+ async accountKey(): Promise<string> {
188
+ await this.ensure();
189
+ const { rows } = await this.db.query("SELECT key_pem FROM acme_account WHERE id = 1");
190
+ const kept = rows[0]?.["key_pem"];
191
+ if (typeof kept === "string" && kept !== "") return kept;
192
+ const made = (await acme.crypto.createPrivateKey()).toString();
193
+ await this.db.query(
194
+ "INSERT INTO acme_account (id, key_pem) VALUES (1, $1) ON CONFLICT (id) DO NOTHING",
195
+ [made],
196
+ );
197
+ // Somebody else may have won the race to insert; theirs is the account now.
198
+ const again = await this.db.query("SELECT key_pem FROM acme_account WHERE id = 1");
199
+ const winner = again.rows[0]?.["key_pem"];
200
+ return typeof winner === "string" && winner !== "" ? winner : made;
201
+ }
202
+
203
+ /** The names one handle's certificate covers: every server it has, and the handle itself. */
204
+ namesFor(handle: string): string[] {
205
+ return [`*.${handle}.${this.zone.zone}`, `${handle}.${this.zone.zone}`];
206
+ }
207
+
208
+ private async stored(handle: string): Promise<Stored | null> {
209
+ await this.ensure();
210
+ const { rows } = await this.db.query(
211
+ "SELECT cert, key, expires_at FROM certs WHERE handle = $1",
212
+ [handle],
213
+ );
214
+ const row = rows[0];
215
+ if (!row) return null;
216
+ const cert = row["cert"];
217
+ const key = row["key"];
218
+ const expires = row["expires_at"];
219
+ if (typeof cert !== "string" || typeof key !== "string") return null;
220
+ const expiresAt = expires instanceof Date ? expires.getTime() : new Date(String(expires)).getTime();
221
+ if (!Number.isFinite(expiresAt)) return null;
222
+ return { cert, key, expiresAt };
223
+ }
224
+
225
+ /**
226
+ * A handle's certificate, or what is being done about the lack of one.
227
+ *
228
+ * Never blocks on the CA. With nothing stored, or something failed long
229
+ * enough ago to be worth another go, an order starts in the background and
230
+ * the answer is "issuing". With one stored that is near its end, the answer
231
+ * is still the old one -- it is valid -- and a renewal starts alongside.
232
+ */
233
+ async forHandle(handle: string): Promise<CertState> {
234
+ const kept = await this.stored(handle);
235
+ const inFlight = this.work.get(handle);
236
+
237
+ if (kept && kept.expiresAt > this.now()) {
238
+ const renewing = kept.expiresAt - this.now() < this.renewBeforeMs;
239
+ if (renewing && !(inFlight?.kind === "issuing")) this.begin(handle, "renewal");
240
+ return {
241
+ status: "ready",
242
+ cert: kept.cert,
243
+ key: kept.key,
244
+ expiresAt: kept.expiresAt,
245
+ renewing: renewing || inFlight?.kind === "issuing",
246
+ };
247
+ }
248
+
249
+ if (inFlight?.kind === "issuing") return { status: "issuing", since: inFlight.since };
250
+ if (inFlight?.kind === "failed" && this.now() - inFlight.at < this.retryAfterMs) {
251
+ return { status: "failed", error: inFlight.error, at: inFlight.at };
252
+ }
253
+ const started = this.begin(handle, kept ? "replacement of an expired certificate" : "first certificate");
254
+ return { status: "issuing", since: started.since };
255
+ }
256
+
257
+ /** Order in the background, and remember how it went. */
258
+ private begin(handle: string, why: string): { since: number } {
259
+ const since = this.now();
260
+ const names = this.namesFor(handle);
261
+ this.log(` Ordering a certificate for ${names.join(" and ")} (${why}).`);
262
+ const done = this.issuer
263
+ .issue(names, {
264
+ set: (host, value) => this.zone.add(host, "TXT", value, 600),
265
+ clear: (host, value) => this.zone.remove(host, "TXT", value),
266
+ })
267
+ .then(async (issued) => {
268
+ await this.ensure();
269
+ await this.db.query(
270
+ `INSERT INTO certs (handle, cert, key, expires_at, issued_at) VALUES ($1, $2, $3, $4, now())
271
+ ON CONFLICT (handle) DO UPDATE SET cert = EXCLUDED.cert, key = EXCLUDED.key,
272
+ expires_at = EXCLUDED.expires_at, issued_at = now()`,
273
+ [handle, issued.cert, issued.key, new Date(issued.expiresAt).toISOString()],
274
+ );
275
+ const days = Math.max(0, Math.round((issued.expiresAt - this.now()) / (24 * 60 * 60 * 1000)));
276
+ this.log(` Certificate for *.${handle}.${this.zone.zone} issued, good for ${days} days.`);
277
+ this.work.delete(handle);
278
+ })
279
+ .catch((error: unknown) => {
280
+ const message = error instanceof Error ? error.message : String(error);
281
+ this.log(` Certificate for *.${handle}.${this.zone.zone} failed: ${message}`);
282
+ this.work.set(handle, { kind: "failed", error: message, at: this.now() });
283
+ });
284
+ const work: Work = { kind: "issuing", since, done };
285
+ this.work.set(handle, work);
286
+ return work;
287
+ }
288
+
289
+ /** For tests and shutdown: whatever order is in flight for a handle. */
290
+ async settle(handle: string): Promise<void> {
291
+ const inFlight = this.work.get(handle);
292
+ if (inFlight?.kind === "issuing") await inFlight.done;
293
+ }
294
+ }
package/src/channels.ts CHANGED
@@ -64,6 +64,8 @@ export const GIVE_UP = 5;
64
64
  * channel that is quiet for half a minute is not being quiet, it is dead.
65
65
  */
66
66
  export const STALL = 30_000;
67
+ /** How long an on-demand channel stays up with nobody watching. */
68
+ export const IDLE = 60_000;
67
69
  /** How much of what ffmpeg said to keep, for the last line when it dies. */
68
70
  const TAIL = 2000;
69
71
  /**
@@ -116,6 +118,8 @@ export interface ChannelOptions {
116
118
  ffmpeg: string[];
117
119
  onStart?: (info: ChannelInfo) => void;
118
120
  onEnd?: (info: ChannelInfo) => void;
121
+ /** How long an on-demand channel outlives its last viewer. Tests shorten it. */
122
+ idleMs?: number;
119
123
  }
120
124
 
121
125
  /**
@@ -151,6 +155,13 @@ export class Channel {
151
155
  */
152
156
  private recent: Buffer[] = [];
153
157
  private recentBytes = 0;
158
+ /**
159
+ * Started for whoever asked and stopped when nobody is left. A catalog
160
+ * channel is one of thousands; keeping every one that was ever clicked
161
+ * running would be a decoder per click, for ever.
162
+ */
163
+ ephemeral = false;
164
+ private idle: ReturnType<typeof setTimeout> | null = null;
154
165
 
155
166
  constructor(
156
167
  readonly info: ChannelInfo,
@@ -466,12 +477,25 @@ export class Channel {
466
477
  }
467
478
  this.listeners.add(listener);
468
479
  this.info.listeners = this.listeners.size;
480
+ if (this.idle) clearTimeout(this.idle);
481
+ this.idle = null;
469
482
  return () => {
470
483
  this.listeners.delete(listener);
471
484
  this.info.listeners = this.listeners.size;
485
+ if (this.ephemeral && this.listeners.size === 0) this.idleOut();
472
486
  };
473
487
  }
474
488
 
489
+ /** Nobody is watching an on-demand channel: give it a minute, then stop. */
490
+ private idleOut(): void {
491
+ if (this.idle) clearTimeout(this.idle);
492
+ this.idle = setTimeout(() => {
493
+ this.idle = null;
494
+ if (this.ephemeral && this.listeners.size === 0) this.close();
495
+ }, this.options.idleMs ?? IDLE);
496
+ this.idle.unref?.();
497
+ }
498
+
475
499
  close(): void {
476
500
  if (this.closing) return;
477
501
  this.closing = true;
@@ -480,6 +504,8 @@ export class Channel {
480
504
  this.timer = null;
481
505
  if (this.watchdog) clearTimeout(this.watchdog);
482
506
  this.watchdog = null;
507
+ if (this.idle) clearTimeout(this.idle);
508
+ this.idle = null;
483
509
  const said = lastLine(this.stderr);
484
510
  if (said && !this.info.error) this.info.error = said;
485
511
  const child = this.child;
@@ -615,6 +641,21 @@ export class Channels {
615
641
  return this.open.get(id)?.info.via === "pull";
616
642
  }
617
643
 
644
+ /** Mark a channel as on demand: it stops itself a minute after its last viewer leaves. */
645
+ ephemeral(id: string): void {
646
+ const channel = this.open.get(id);
647
+ if (!channel) return;
648
+ channel.ephemeral = true;
649
+ if (channel.listeners.size === 0) channel.listen({ write: () => true, end: () => undefined })();
650
+ }
651
+
652
+ /** How many on-demand channels are up, for a ceiling on decoders. */
653
+ get ephemeralCount(): number {
654
+ let total = 0;
655
+ for (const channel of this.open.values()) if (channel.ephemeral) total += 1;
656
+ return total;
657
+ }
658
+
618
659
  /** What a listener should be told this channel is. */
619
660
  contentType(id: string): string {
620
661
  return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
package/src/directory.ts CHANGED
@@ -55,6 +55,14 @@ export interface Listing {
55
55
  * an older nixamp that only knows about `url`.
56
56
  */
57
57
  audio: string;
58
+ /**
59
+ * The link that administers the server, as the publisher announced it.
60
+ *
61
+ * Kept so the owner can open their own machine as its administrator from
62
+ * the directory, and handed out to nobody else: the listing route strips it
63
+ * for anyone but the account that owns the listing.
64
+ */
65
+ admin: string;
58
66
  tracks: number;
59
67
  nowPlaying: string;
60
68
  /**
@@ -103,6 +111,8 @@ export interface Announcement {
103
111
  url: string;
104
112
  /** Where the audio actually is. See `Listing.audio`. */
105
113
  audio?: string;
114
+ /** The admin share link, same origin as `url`. Kept for the owner alone. */
115
+ admin?: string;
106
116
  tracks: number;
107
117
  nowPlaying: string;
108
118
  /** Absent from an older publisher, which is read as "unknown, say playing". */
@@ -160,6 +170,10 @@ export function parseAnnouncement(input: unknown): Announcement | null {
160
170
  const offered = typeof record["audio"] === "string" ? record["audio"] : "";
161
171
  const parsed = offered ? publishable(offered) : null;
162
172
  const audio = parsed !== null && parsed.origin === listen.origin ? offered : "";
173
+ // The admin link is held to the same rule: it names this server or nothing.
174
+ const adminOffered = typeof record["admin"] === "string" ? record["admin"] : "";
175
+ const adminParsed = adminOffered ? publishable(adminOffered) : null;
176
+ const admin = adminParsed !== null && adminParsed.origin === listen.origin ? adminOffered : "";
163
177
 
164
178
  const name = clean(record["name"], MAX_NAME);
165
179
  const tracks = Number(record["tracks"]);
@@ -168,6 +182,7 @@ export function parseAnnouncement(input: unknown): Announcement | null {
168
182
  name: name || "a nixamp",
169
183
  url,
170
184
  ...(audio ? { audio } : {}),
185
+ ...(admin ? { admin } : {}),
171
186
  tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
172
187
  nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
173
188
  ...(typeof record["playing"] === "boolean" ? { playing: record["playing"] } : {}),
@@ -261,6 +276,7 @@ export class Directory {
261
276
  // older publisher renewing an entry should not blank the address the
262
277
  // phone line is playing from.
263
278
  audio: announcement.audio ?? existing?.audio ?? "",
279
+ admin: announcement.admin ?? existing?.admin ?? "",
264
280
  tracks: announcement.tracks,
265
281
  nowPlaying: announcement.nowPlaying,
266
282
  // An older publisher says nothing about either; "playing" keeps what a