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
@@ -0,0 +1,100 @@
1
+ export interface CatalogEntry {
2
+ /** Stable across refreshes: a hash of the source, so a link to it survives. */
3
+ id: string;
4
+ title: string;
5
+ group: string;
6
+ logo: string;
7
+ /** Where the bytes are. Never shown to a listener. */
8
+ source: string;
9
+ live: boolean;
10
+ /** Seconds, when the list said; 0 for live and for "did not say". */
11
+ duration: number;
12
+ }
13
+ export interface CatalogInfo {
14
+ id: string;
15
+ name: string;
16
+ /** Where the list is read from. Shown to administrators only. */
17
+ source: string;
18
+ addedAt: number;
19
+ refreshedAt: number;
20
+ entries: number;
21
+ live: number;
22
+ vod: number;
23
+ groups: number;
24
+ /** What went wrong the last time it was read, or "". */
25
+ error: string;
26
+ }
27
+ /** A list bigger than this is not a playlist, it is a mistake. */
28
+ export declare const MAX_LIST_BYTES: number;
29
+ /** How long to wait for a provider before giving up on the list. */
30
+ export declare const FETCH_TIMEOUT_MS = 30000;
31
+ /**
32
+ * Live, or on demand?
33
+ *
34
+ * The list rarely says outright. A stated length is a film; a file extension
35
+ * is a file; an Xtream-shaped URL says /movie/ or /series/ for on demand and
36
+ * /live/ for a channel; and a bare stream key, an .m3u8 or an .ts is a channel.
37
+ */
38
+ export declare function isLiveEntry(source: string, duration: number): boolean;
39
+ export declare function entryId(source: string): string;
40
+ /**
41
+ * Parse an extended m3u: `#EXTINF:-1 tvg-name="CNN" group-title="News",CNN HD`
42
+ * decorates the URL on the next line, and `#EXTGRP:News` names the group for
43
+ * lists that write it that way instead.
44
+ */
45
+ export declare function parseCatalog(text: string, base: string): CatalogEntry[];
46
+ export interface Group {
47
+ name: string;
48
+ count: number;
49
+ live: number;
50
+ vod: number;
51
+ }
52
+ /** Read a list, from disk or over the network, within reason. */
53
+ export declare function readCatalog(source: string, send?: typeof fetch): Promise<string>;
54
+ /**
55
+ * The catalogs on one server, and their entries.
56
+ *
57
+ * The index is small and written whenever it changes; each catalog's entries
58
+ * are kept in their own file so a restart does not have to ask every provider
59
+ * again before anybody can browse.
60
+ */
61
+ export declare class Catalogs {
62
+ private readonly dir;
63
+ private readonly port;
64
+ private readonly send;
65
+ private readonly stored;
66
+ private readonly entries;
67
+ constructor(dir: string, port: number, send?: typeof fetch);
68
+ /** What was here last time, without asking any provider yet. */
69
+ load(): void;
70
+ /** Read every catalog that has no entries on disk. For boot. */
71
+ warm(): Promise<void>;
72
+ private cachePath;
73
+ private save;
74
+ private info;
75
+ list(): CatalogInfo[];
76
+ get(id: string): CatalogInfo | null;
77
+ /** Add a list and read it. The name is what a person called it, or the list's own filename. */
78
+ add(source: string, name: string): Promise<CatalogInfo>;
79
+ /** Read the list again. The old entries stay if the provider is down. */
80
+ refresh(id: string): Promise<CatalogInfo | null>;
81
+ remove(id: string): boolean;
82
+ groups(id: string): Group[] | null;
83
+ /** A page of entries: in one group, or all of them, matching a search. */
84
+ entries_(id: string, query: {
85
+ group?: string;
86
+ q?: string;
87
+ offset?: number;
88
+ limit?: number;
89
+ }): {
90
+ total: number;
91
+ entries: CatalogEntry[];
92
+ } | null;
93
+ entry(id: string, entryId_: string): CatalogEntry | null;
94
+ }
95
+ /** What a listener is shown of an entry: everything but where the bytes are. */
96
+ export declare function shownEntry(entry: CatalogEntry): Omit<CatalogEntry, "source">;
97
+ /** What a listener is shown of a catalog: everything but where the list is. */
98
+ export declare function shownCatalog(info: CatalogInfo, admin: boolean): Omit<CatalogInfo, "source"> & {
99
+ source?: string;
100
+ };
Binary file
@@ -0,0 +1,112 @@
1
+ import type { DnsZone } from "./dns.ts";
2
+ import type { Queryable } from "./follows.ts";
3
+ export interface Issued {
4
+ /** PEM, the full chain. */
5
+ cert: string;
6
+ /** PEM. */
7
+ key: string;
8
+ /** Epoch milliseconds. */
9
+ expiresAt: number;
10
+ }
11
+ /** Where a DNS-01 challenge gets written, and unwritten. */
12
+ export interface Challenge {
13
+ set(host: string, value: string): Promise<void>;
14
+ clear(host: string, value: string): Promise<void>;
15
+ }
16
+ /** Something that turns a list of names into a certificate. ACME, in production. */
17
+ export interface Issuer {
18
+ issue(names: string[], challenge: Challenge): Promise<Issued>;
19
+ }
20
+ export interface AcmeIssuerOptions {
21
+ /** acme.directory.letsencrypt.production, or .staging while trying things out. */
22
+ directoryUrl: string;
23
+ /** The account contact Let's Encrypt writes to about expiry. */
24
+ email: string;
25
+ /** The account's private key, PEM. Kept by the caller; see Certs.accountKey. */
26
+ accountKey: () => Promise<string>;
27
+ /**
28
+ * How long to wait after writing the TXT before letting the CA look for it.
29
+ * A registrar's API answers at once; the world's resolvers do not.
30
+ */
31
+ propagationMs?: number;
32
+ sleep?: (ms: number) => Promise<void>;
33
+ }
34
+ /** A minute is what Porkbun's 600-second floor tends to cost in practice. */
35
+ export declare const DEFAULT_PROPAGATION_MS = 60000;
36
+ /** Renew inside the last thirty days, which is what Let's Encrypt recommends. */
37
+ export declare const RENEW_BEFORE_MS: number;
38
+ /** A failed order is worth trying again, but not every time somebody asks. */
39
+ export declare const RETRY_AFTER_MS: number;
40
+ /**
41
+ * Let's Encrypt, through acme-client, by DNS-01 only: a wildcard cannot be
42
+ * proved any other way, and the zone is the one thing nixamp.com does hold.
43
+ */
44
+ export declare class AcmeIssuer implements Issuer {
45
+ private readonly options;
46
+ constructor(options: AcmeIssuerOptions);
47
+ issue(names: string[], challenge: Challenge): Promise<Issued>;
48
+ }
49
+ export type CertState = {
50
+ status: "ready";
51
+ cert: string;
52
+ key: string;
53
+ expiresAt: number;
54
+ renewing: boolean;
55
+ } | {
56
+ status: "issuing";
57
+ since: number;
58
+ } | {
59
+ status: "failed";
60
+ error: string;
61
+ at: number;
62
+ } | {
63
+ status: "none";
64
+ };
65
+ export interface CertsOptions {
66
+ renewBeforeMs?: number;
67
+ retryAfterMs?: number;
68
+ now?: () => number;
69
+ log?: (line: string) => void;
70
+ }
71
+ /**
72
+ * The certificates, one per handle, and the work of getting them.
73
+ *
74
+ * The rows are the truth and survive a restart; what is in flight lives in
75
+ * memory, because nixamp.com is one process and an order interrupted by a
76
+ * deploy is simply started again when next asked for.
77
+ */
78
+ export declare class Certs {
79
+ private readonly db;
80
+ private readonly zone;
81
+ private readonly issuer;
82
+ private ready;
83
+ private readonly work;
84
+ private readonly renewBeforeMs;
85
+ private readonly retryAfterMs;
86
+ private readonly now;
87
+ private readonly log;
88
+ constructor(db: Queryable, zone: DnsZone, issuer: Issuer, options?: CertsOptions);
89
+ private ensure;
90
+ /**
91
+ * The ACME account key, made once and kept. Losing it would not lose any
92
+ * certificate, but every renewal would register a new account, and Let's
93
+ * Encrypt rate-limits those too.
94
+ */
95
+ accountKey(): Promise<string>;
96
+ /** The names one handle's certificate covers: every server it has, and the handle itself. */
97
+ namesFor(handle: string): string[];
98
+ private stored;
99
+ /**
100
+ * A handle's certificate, or what is being done about the lack of one.
101
+ *
102
+ * Never blocks on the CA. With nothing stored, or something failed long
103
+ * enough ago to be worth another go, an order starts in the background and
104
+ * the answer is "issuing". With one stored that is near its end, the answer
105
+ * is still the old one -- it is valid -- and a renewal starts alongside.
106
+ */
107
+ forHandle(handle: string): Promise<CertState>;
108
+ /** Order in the background, and remember how it went. */
109
+ private begin;
110
+ /** For tests and shutdown: whatever order is in flight for a handle. */
111
+ settle(handle: string): Promise<void>;
112
+ }
package/dist/certs.js ADDED
@@ -0,0 +1,217 @@
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
+ /** A minute is what Porkbun's 600-second floor tends to cost in practice. */
23
+ export const DEFAULT_PROPAGATION_MS = 60_000;
24
+ /** Renew inside the last thirty days, which is what Let's Encrypt recommends. */
25
+ export const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
26
+ /** A failed order is worth trying again, but not every time somebody asks. */
27
+ export const RETRY_AFTER_MS = 60 * 60 * 1000;
28
+ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
29
+ /**
30
+ * Let's Encrypt, through acme-client, by DNS-01 only: a wildcard cannot be
31
+ * proved any other way, and the zone is the one thing nixamp.com does hold.
32
+ */
33
+ export class AcmeIssuer {
34
+ options;
35
+ constructor(options) {
36
+ this.options = options;
37
+ }
38
+ async issue(names, challenge) {
39
+ const [first, ...rest] = names;
40
+ if (!first)
41
+ throw new Error("nothing to issue a certificate for");
42
+ const propagationMs = this.options.propagationMs ?? DEFAULT_PROPAGATION_MS;
43
+ const sleep = this.options.sleep ?? wait;
44
+ const client = new acme.Client({
45
+ directoryUrl: this.options.directoryUrl,
46
+ accountKey: await this.options.accountKey(),
47
+ });
48
+ // A fresh key per certificate. Reusing the account key for the
49
+ // certificate would make one leak two.
50
+ const [key, csr] = await acme.crypto.createCsr({ commonName: first, altNames: [first, ...rest] });
51
+ const cert = await client.auto({
52
+ csr,
53
+ email: this.options.email,
54
+ termsOfServiceAgreed: true,
55
+ challengePriority: ["dns-01"],
56
+ // acme-client would otherwise resolve the TXT itself before telling the
57
+ // CA to, and from wherever nixamp.com runs that lookup can lag the CA's.
58
+ skipChallengeVerification: true,
59
+ challengeCreateFn: async (authz, _challenge, keyAuthorization) => {
60
+ await challenge.set(`_acme-challenge.${authz.identifier.value}`, keyAuthorization);
61
+ await sleep(propagationMs);
62
+ },
63
+ challengeRemoveFn: async (authz, _challenge, keyAuthorization) => {
64
+ await challenge.clear(`_acme-challenge.${authz.identifier.value}`, keyAuthorization);
65
+ },
66
+ });
67
+ const info = acme.crypto.readCertificateInfo(cert);
68
+ return { cert, key: key.toString(), expiresAt: info.notAfter.getTime() };
69
+ }
70
+ }
71
+ const SCHEMA = `
72
+ CREATE TABLE IF NOT EXISTS certs (
73
+ handle TEXT PRIMARY KEY,
74
+ cert TEXT NOT NULL,
75
+ key TEXT NOT NULL,
76
+ expires_at TIMESTAMPTZ NOT NULL,
77
+ issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
78
+ );
79
+ CREATE TABLE IF NOT EXISTS acme_account (
80
+ id INTEGER PRIMARY KEY,
81
+ key_pem TEXT NOT NULL
82
+ );
83
+ `;
84
+ /**
85
+ * The certificates, one per handle, and the work of getting them.
86
+ *
87
+ * The rows are the truth and survive a restart; what is in flight lives in
88
+ * memory, because nixamp.com is one process and an order interrupted by a
89
+ * deploy is simply started again when next asked for.
90
+ */
91
+ export class Certs {
92
+ db;
93
+ zone;
94
+ issuer;
95
+ ready = null;
96
+ work = new Map();
97
+ renewBeforeMs;
98
+ retryAfterMs;
99
+ now;
100
+ log;
101
+ constructor(db, zone, issuer, options = {}) {
102
+ this.db = db;
103
+ this.zone = zone;
104
+ this.issuer = issuer;
105
+ this.renewBeforeMs = options.renewBeforeMs ?? RENEW_BEFORE_MS;
106
+ this.retryAfterMs = options.retryAfterMs ?? RETRY_AFTER_MS;
107
+ this.now = options.now ?? Date.now;
108
+ this.log = options.log ?? (() => undefined);
109
+ }
110
+ async ensure() {
111
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
112
+ await this.ready;
113
+ }
114
+ /**
115
+ * The ACME account key, made once and kept. Losing it would not lose any
116
+ * certificate, but every renewal would register a new account, and Let's
117
+ * Encrypt rate-limits those too.
118
+ */
119
+ async accountKey() {
120
+ await this.ensure();
121
+ const { rows } = await this.db.query("SELECT key_pem FROM acme_account WHERE id = 1");
122
+ const kept = rows[0]?.["key_pem"];
123
+ if (typeof kept === "string" && kept !== "")
124
+ return kept;
125
+ const made = (await acme.crypto.createPrivateKey()).toString();
126
+ await this.db.query("INSERT INTO acme_account (id, key_pem) VALUES (1, $1) ON CONFLICT (id) DO NOTHING", [made]);
127
+ // Somebody else may have won the race to insert; theirs is the account now.
128
+ const again = await this.db.query("SELECT key_pem FROM acme_account WHERE id = 1");
129
+ const winner = again.rows[0]?.["key_pem"];
130
+ return typeof winner === "string" && winner !== "" ? winner : made;
131
+ }
132
+ /** The names one handle's certificate covers: every server it has, and the handle itself. */
133
+ namesFor(handle) {
134
+ return [`*.${handle}.${this.zone.zone}`, `${handle}.${this.zone.zone}`];
135
+ }
136
+ async stored(handle) {
137
+ await this.ensure();
138
+ const { rows } = await this.db.query("SELECT cert, key, expires_at FROM certs WHERE handle = $1", [handle]);
139
+ const row = rows[0];
140
+ if (!row)
141
+ return null;
142
+ const cert = row["cert"];
143
+ const key = row["key"];
144
+ const expires = row["expires_at"];
145
+ if (typeof cert !== "string" || typeof key !== "string")
146
+ return null;
147
+ const expiresAt = expires instanceof Date ? expires.getTime() : new Date(String(expires)).getTime();
148
+ if (!Number.isFinite(expiresAt))
149
+ return null;
150
+ return { cert, key, expiresAt };
151
+ }
152
+ /**
153
+ * A handle's certificate, or what is being done about the lack of one.
154
+ *
155
+ * Never blocks on the CA. With nothing stored, or something failed long
156
+ * enough ago to be worth another go, an order starts in the background and
157
+ * the answer is "issuing". With one stored that is near its end, the answer
158
+ * is still the old one -- it is valid -- and a renewal starts alongside.
159
+ */
160
+ async forHandle(handle) {
161
+ const kept = await this.stored(handle);
162
+ const inFlight = this.work.get(handle);
163
+ if (kept && kept.expiresAt > this.now()) {
164
+ const renewing = kept.expiresAt - this.now() < this.renewBeforeMs;
165
+ if (renewing && !(inFlight?.kind === "issuing"))
166
+ this.begin(handle, "renewal");
167
+ return {
168
+ status: "ready",
169
+ cert: kept.cert,
170
+ key: kept.key,
171
+ expiresAt: kept.expiresAt,
172
+ renewing: renewing || inFlight?.kind === "issuing",
173
+ };
174
+ }
175
+ if (inFlight?.kind === "issuing")
176
+ return { status: "issuing", since: inFlight.since };
177
+ if (inFlight?.kind === "failed" && this.now() - inFlight.at < this.retryAfterMs) {
178
+ return { status: "failed", error: inFlight.error, at: inFlight.at };
179
+ }
180
+ const started = this.begin(handle, kept ? "replacement of an expired certificate" : "first certificate");
181
+ return { status: "issuing", since: started.since };
182
+ }
183
+ /** Order in the background, and remember how it went. */
184
+ begin(handle, why) {
185
+ const since = this.now();
186
+ const names = this.namesFor(handle);
187
+ this.log(` Ordering a certificate for ${names.join(" and ")} (${why}).`);
188
+ const done = this.issuer
189
+ .issue(names, {
190
+ set: (host, value) => this.zone.add(host, "TXT", value, 600),
191
+ clear: (host, value) => this.zone.remove(host, "TXT", value),
192
+ })
193
+ .then(async (issued) => {
194
+ await this.ensure();
195
+ await this.db.query(`INSERT INTO certs (handle, cert, key, expires_at, issued_at) VALUES ($1, $2, $3, $4, now())
196
+ ON CONFLICT (handle) DO UPDATE SET cert = EXCLUDED.cert, key = EXCLUDED.key,
197
+ expires_at = EXCLUDED.expires_at, issued_at = now()`, [handle, issued.cert, issued.key, new Date(issued.expiresAt).toISOString()]);
198
+ const days = Math.max(0, Math.round((issued.expiresAt - this.now()) / (24 * 60 * 60 * 1000)));
199
+ this.log(` Certificate for *.${handle}.${this.zone.zone} issued, good for ${days} days.`);
200
+ this.work.delete(handle);
201
+ })
202
+ .catch((error) => {
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ this.log(` Certificate for *.${handle}.${this.zone.zone} failed: ${message}`);
205
+ this.work.set(handle, { kind: "failed", error: message, at: this.now() });
206
+ });
207
+ const work = { kind: "issuing", since, done };
208
+ this.work.set(handle, work);
209
+ return work;
210
+ }
211
+ /** For tests and shutdown: whatever order is in flight for a handle. */
212
+ async settle(handle) {
213
+ const inFlight = this.work.get(handle);
214
+ if (inFlight?.kind === "issuing")
215
+ await inFlight.done;
216
+ }
217
+ }
@@ -40,6 +40,8 @@ export declare const GIVE_UP = 5;
40
40
  * channel that is quiet for half a minute is not being quiet, it is dead.
41
41
  */
42
42
  export declare const STALL = 30000;
43
+ /** How long an on-demand channel stays up with nobody watching. */
44
+ export declare const IDLE = 60000;
43
45
  /**
44
46
  * How much of the recent stream a newcomer is handed. About six seconds of
45
47
  * 720p television, and a couple of seconds of 192k MP3: enough to play
@@ -53,6 +55,8 @@ export interface ChannelOptions {
53
55
  ffmpeg: string[];
54
56
  onStart?: (info: ChannelInfo) => void;
55
57
  onEnd?: (info: ChannelInfo) => void;
58
+ /** How long an on-demand channel outlives its last viewer. Tests shorten it. */
59
+ idleMs?: number;
56
60
  }
57
61
  /**
58
62
  * One live source, and its audience.
@@ -90,6 +94,13 @@ export declare class Channel {
90
94
  */
91
95
  private recent;
92
96
  private recentBytes;
97
+ /**
98
+ * Started for whoever asked and stopped when nobody is left. A catalog
99
+ * channel is one of thousands; keeping every one that was ever clicked
100
+ * running would be a decoder per click, for ever.
101
+ */
102
+ ephemeral: boolean;
103
+ private idle;
93
104
  constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
94
105
  start(format: string): void;
95
106
  /**
@@ -162,6 +173,8 @@ export declare class Channel {
162
173
  /** Write to everyone, and drop anybody whose socket has gone. */
163
174
  private send;
164
175
  listen(listener: Listener): () => void;
176
+ /** Nobody is watching an on-demand channel: give it a minute, then stop. */
177
+ private idleOut;
165
178
  close(): void;
166
179
  }
167
180
  /**
@@ -203,6 +216,10 @@ export declare class Channels {
203
216
  restart(id: string): boolean;
204
217
  /** Whether a channel is one we fetch ourselves, and so can start over. */
205
218
  pulled(id: string): boolean;
219
+ /** Mark a channel as on demand: it stops itself a minute after its last viewer leaves. */
220
+ ephemeral(id: string): void;
221
+ /** How many on-demand channels are up, for a ceiling on decoders. */
222
+ get ephemeralCount(): number;
206
223
  /** What a listener should be told this channel is. */
207
224
  contentType(id: string): string;
208
225
  /** Attach a listener, or null when nothing is playing on that channel. */
package/dist/channels.js CHANGED
@@ -31,6 +31,8 @@ export const GIVE_UP = 5;
31
31
  * channel that is quiet for half a minute is not being quiet, it is dead.
32
32
  */
33
33
  export const STALL = 30_000;
34
+ /** How long an on-demand channel stays up with nobody watching. */
35
+ export const IDLE = 60_000;
34
36
  /** How much of what ffmpeg said to keep, for the last line when it dies. */
35
37
  const TAIL = 2000;
36
38
  /**
@@ -111,6 +113,13 @@ export class Channel {
111
113
  */
112
114
  recent = [];
113
115
  recentBytes = 0;
116
+ /**
117
+ * Started for whoever asked and stopped when nobody is left. A catalog
118
+ * channel is one of thousands; keeping every one that was ever clicked
119
+ * running would be a decoder per click, for ever.
120
+ */
121
+ ephemeral = false;
122
+ idle = null;
114
123
  constructor(info, options, onGone) {
115
124
  this.info = info;
116
125
  this.options = options;
@@ -418,11 +427,27 @@ export class Channel {
418
427
  }
419
428
  this.listeners.add(listener);
420
429
  this.info.listeners = this.listeners.size;
430
+ if (this.idle)
431
+ clearTimeout(this.idle);
432
+ this.idle = null;
421
433
  return () => {
422
434
  this.listeners.delete(listener);
423
435
  this.info.listeners = this.listeners.size;
436
+ if (this.ephemeral && this.listeners.size === 0)
437
+ this.idleOut();
424
438
  };
425
439
  }
440
+ /** Nobody is watching an on-demand channel: give it a minute, then stop. */
441
+ idleOut() {
442
+ if (this.idle)
443
+ clearTimeout(this.idle);
444
+ this.idle = setTimeout(() => {
445
+ this.idle = null;
446
+ if (this.ephemeral && this.listeners.size === 0)
447
+ this.close();
448
+ }, this.options.idleMs ?? IDLE);
449
+ this.idle.unref?.();
450
+ }
426
451
  close() {
427
452
  if (this.closing)
428
453
  return;
@@ -434,6 +459,9 @@ export class Channel {
434
459
  if (this.watchdog)
435
460
  clearTimeout(this.watchdog);
436
461
  this.watchdog = null;
462
+ if (this.idle)
463
+ clearTimeout(this.idle);
464
+ this.idle = null;
437
465
  const said = lastLine(this.stderr);
438
466
  if (said && !this.info.error)
439
467
  this.info.error = said;
@@ -551,6 +579,23 @@ export class Channels {
551
579
  pulled(id) {
552
580
  return this.open.get(id)?.info.via === "pull";
553
581
  }
582
+ /** Mark a channel as on demand: it stops itself a minute after its last viewer leaves. */
583
+ ephemeral(id) {
584
+ const channel = this.open.get(id);
585
+ if (!channel)
586
+ return;
587
+ channel.ephemeral = true;
588
+ if (channel.listeners.size === 0)
589
+ channel.listen({ write: () => true, end: () => undefined })();
590
+ }
591
+ /** How many on-demand channels are up, for a ceiling on decoders. */
592
+ get ephemeralCount() {
593
+ let total = 0;
594
+ for (const channel of this.open.values())
595
+ if (channel.ephemeral)
596
+ total += 1;
597
+ return total;
598
+ }
554
599
  /** What a listener should be told this channel is. */
555
600
  contentType(id) {
556
601
  return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
@@ -51,6 +51,14 @@ export interface Listing {
51
51
  * an older nixamp that only knows about `url`.
52
52
  */
53
53
  audio: string;
54
+ /**
55
+ * The link that administers the server, as the publisher announced it.
56
+ *
57
+ * Kept so the owner can open their own machine as its administrator from
58
+ * the directory, and handed out to nobody else: the listing route strips it
59
+ * for anyone but the account that owns the listing.
60
+ */
61
+ admin: string;
54
62
  tracks: number;
55
63
  nowPlaying: string;
56
64
  /**
@@ -96,6 +104,8 @@ export interface Announcement {
96
104
  url: string;
97
105
  /** Where the audio actually is. See `Listing.audio`. */
98
106
  audio?: string;
107
+ /** The admin share link, same origin as `url`. Kept for the owner alone. */
108
+ admin?: string;
99
109
  tracks: number;
100
110
  nowPlaying: string;
101
111
  /** Absent from an older publisher, which is read as "unknown, say playing". */
package/dist/directory.js CHANGED
@@ -68,6 +68,10 @@ export function parseAnnouncement(input) {
68
68
  const offered = typeof record["audio"] === "string" ? record["audio"] : "";
69
69
  const parsed = offered ? publishable(offered) : null;
70
70
  const audio = parsed !== null && parsed.origin === listen.origin ? offered : "";
71
+ // The admin link is held to the same rule: it names this server or nothing.
72
+ const adminOffered = typeof record["admin"] === "string" ? record["admin"] : "";
73
+ const adminParsed = adminOffered ? publishable(adminOffered) : null;
74
+ const admin = adminParsed !== null && adminParsed.origin === listen.origin ? adminOffered : "";
71
75
  const name = clean(record["name"], MAX_NAME);
72
76
  const tracks = Number(record["tracks"]);
73
77
  return {
@@ -75,6 +79,7 @@ export function parseAnnouncement(input) {
75
79
  name: name || "a nixamp",
76
80
  url,
77
81
  ...(audio ? { audio } : {}),
82
+ ...(admin ? { admin } : {}),
78
83
  tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
79
84
  nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
80
85
  ...(typeof record["playing"] === "boolean" ? { playing: record["playing"] } : {}),
@@ -167,6 +172,7 @@ export class Directory {
167
172
  // older publisher renewing an entry should not blank the address the
168
173
  // phone line is playing from.
169
174
  audio: announcement.audio ?? existing?.audio ?? "",
175
+ admin: announcement.admin ?? existing?.admin ?? "",
170
176
  tracks: announcement.tracks,
171
177
  nowPlaying: announcement.nowPlaying,
172
178
  // An older publisher says nothing about either; "playing" keeps what a
package/dist/dns.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ export type RecordType = "A" | "AAAA" | "TXT";
2
+ export interface DnsRecord {
3
+ id: string;
4
+ /** The full hostname, e.g. "server2.chovy.nixamp.com". */
5
+ host: string;
6
+ type: RecordType;
7
+ content: string;
8
+ ttl: number;
9
+ }
10
+ export interface DnsZone {
11
+ /** The apex this zone answers for: "nixamp.com". */
12
+ readonly zone: string;
13
+ list(host: string, type: RecordType): Promise<DnsRecord[]>;
14
+ /** Afterwards exactly one record of that host and type exists, with this content. */
15
+ set(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
16
+ /** One more. An ACME order for a wildcard and its apex wants two TXT at once. */
17
+ add(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
18
+ /** All of host and type, or only those whose content matches. */
19
+ remove(host: string, type: RecordType, content?: string): Promise<void>;
20
+ }
21
+ /** 2026, not 1998: an address is either family, and both are first class. */
22
+ export declare function isIPv4(value: unknown): boolean;
23
+ export declare function isIPv6(value: unknown): boolean;
24
+ /** Porkbun's floor. Anything lower is silently raised, so it is raised here, loudly. */
25
+ export declare const MIN_TTL = 600;
26
+ /**
27
+ * A zone kept in memory. For tests, and for a nixamp started without registrar
28
+ * keys, where a name can be handed out and simply will not resolve -- which the
29
+ * operator is told about elsewhere, rather than crashing here.
30
+ */
31
+ export declare class MemoryZone implements DnsZone {
32
+ readonly zone: string;
33
+ readonly records: DnsRecord[];
34
+ private sequence;
35
+ constructor(zone: string);
36
+ list(host: string, type: RecordType): Promise<DnsRecord[]>;
37
+ set(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
38
+ add(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
39
+ remove(host: string, type: RecordType, content?: string): Promise<void>;
40
+ }
41
+ /**
42
+ * Porkbun's v3 DNS API.
43
+ *
44
+ * Every call is a POST carrying both halves of the key in the body -- the
45
+ * field is `secretapikey`, which the API itself names as the usual mistake --
46
+ * and the subdomain is the host with the zone taken off: "server2.chovy" for
47
+ * "server2.chovy.nixamp.com", and nothing at all for the apex.
48
+ */
49
+ export declare class Porkbun implements DnsZone {
50
+ readonly zone: string;
51
+ private readonly apiKey;
52
+ private readonly secretApiKey;
53
+ private readonly send;
54
+ private readonly base;
55
+ constructor(zone: string, apiKey: string, secretApiKey: string, send?: typeof fetch, base?: string);
56
+ /** The part before the zone, or "" for the zone itself. */
57
+ private sub;
58
+ private call;
59
+ list(host: string, type: RecordType): Promise<DnsRecord[]>;
60
+ add(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
61
+ set(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
62
+ remove(host: string, type: RecordType, content?: string): Promise<void>;
63
+ }