nixamp 0.5.11 → 0.6.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/src/main.ts CHANGED
@@ -75,6 +75,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
75
75
  nixamp login [--with github] sign in to nixamp.com, in a browser or here
76
76
  nixamp logout / whoami forget it, or check it
77
77
  nixamp token create|list|revoke tokens for a machine that cannot sign in
78
+ nixamp server list|add|remove the machines you run, kept against your account
78
79
  nixamp update [version] re-run the installer, keeping your choices
79
80
  nixamp uninstall [--yes] remove everything the installer created
80
81
 
@@ -96,6 +97,9 @@ Options for serve:
96
97
  its own interfaces. Also NIXAMP_PUBLIC_URL
97
98
  --no-lookup do not ask ipinfo.io what this machine's public address is
98
99
  when nothing local looks public
100
+ --tls-cert FILE --tls-key FILE serve https rather than http. Needed by
101
+ anyone opening this from a page that is itself https, since
102
+ a browser refuses every request from https to http
99
103
  --ingest accept a live stream in at POST /api/ingest
100
104
  --rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
101
105
  --rtmp-streams N how many may publish at once (default 3, a port each)
@@ -193,6 +197,18 @@ keeps serving its browser remote. One per user.
193
197
  nixamp admin who is connected, and re-stream to them
194
198
 
195
199
  From inside the player, d hands the music to a daemon without stopping it.
200
+ `,
201
+ server: `nixamp server -- the machines you run.
202
+
203
+ nixamp server list every server on your account
204
+ nixamp server add --here remember the daemon on this machine
205
+ nixamp server add URL --name x remember one somewhere else
206
+ nixamp server remove ID forget it
207
+
208
+ A share link printed in a terminal you have since closed is a server you have
209
+ lost. This keeps the address against your account, so the answer is the same
210
+ here, in the browser and in the desktop app. The share key is kept with it only
211
+ if you pass one, since it is the secret that opens the machine.
196
212
  `,
197
213
  attach: `nixamp attach — the player, in front of the running daemon.
198
214
 
@@ -310,6 +326,11 @@ export async function main(): Promise<void> {
310
326
  process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
311
327
  return;
312
328
  }
329
+ if (first === "server" || first === "servers") {
330
+ const { servers } = await import("./session.ts");
331
+ process.exitCode = await servers(rest);
332
+ return;
333
+ }
313
334
  if (first === "token" || first === "tokens") {
314
335
  const { tokens } = await import("./session.ts");
315
336
  process.exitCode = await tokens(rest);
package/src/server.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { createReadStream, statSync } from "node:fs";
13
13
  import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
14
+ import { createServer as createHttpsServer } from "node:https";
14
15
  import { hostname, networkInterfaces } from "node:os";
15
16
  import { spawn, spawnSync } from "node:child_process";
16
17
  import { readFileSync } from "node:fs";
@@ -27,6 +28,7 @@ import { Ingest, normaliseFormat } from "./ingest.ts";
27
28
  import { Channels, cleanId } from "./channels.ts";
28
29
  import { RtmpListeners } from "./rtmp-in.ts";
29
30
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
31
+ import { Servers } from "./servers.ts";
30
32
  import { DeviceGrants } from "./device.ts";
31
33
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.ts";
32
34
  import {
@@ -131,6 +133,18 @@ export interface ServeOptions {
131
133
  * listing is skipped and the printed links only work inside the house.
132
134
  */
133
135
  publicUrl: string;
136
+ /**
137
+ * A certificate and its key, to serve https rather than http.
138
+ *
139
+ * Needed by anybody whose nixamp is opened from a page that is itself https:
140
+ * a browser refuses every request from an https page to an http one --
141
+ * fetch, event stream and media alike -- and no header on either side lifts
142
+ * that. It is deliberately not required: a nixamp on 192.168.1.5 cannot have
143
+ * a certificate for that address, and forcing one would put a browser
144
+ * warning in front of everybody at home to fix a problem they do not have.
145
+ */
146
+ tlsCert: string;
147
+ tlsKey: string;
134
148
  /**
135
149
  * Ask an outside service what this machine's public address is, when no
136
150
  * interface holds one and none was given. Behind NAT that is the only way to
@@ -188,6 +202,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
188
202
  name: "",
189
203
  publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
190
204
  lookup: true,
205
+ tlsCert: process.env["NIXAMP_TLS_CERT"] ?? "",
206
+ tlsKey: process.env["NIXAMP_TLS_KEY"] ?? "",
191
207
  x402: false,
192
208
  owner: "",
193
209
  ingest: false,
@@ -196,6 +212,11 @@ export function parseServeArgs(argv: string[]): ServeOptions {
196
212
  rtmp: [],
197
213
  };
198
214
  let sawRoot = false;
215
+ const bothOrNeither = (): void => {
216
+ if (Boolean(options.tlsCert) !== Boolean(options.tlsKey)) {
217
+ throw new Error("nixamp serve: --tls-cert and --tls-key go together");
218
+ }
219
+ };
199
220
  for (let i = 0; i < argv.length; i++) {
200
221
  const arg = argv[i] as string;
201
222
  const value = (): string => {
@@ -236,6 +257,10 @@ export function parseServeArgs(argv: string[]): ServeOptions {
236
257
  throw new Error("nixamp serve: --public-url must be a URL, e.g. https://nixamp.example.com");
237
258
  }
238
259
  options.publicUrl = given.replace(/\/+$/, "");
260
+ } else if (arg === "--tls-cert") {
261
+ options.tlsCert = value();
262
+ } else if (arg === "--tls-key") {
263
+ options.tlsKey = value();
239
264
  } else if (arg === "--no-lookup") {
240
265
  options.lookup = false;
241
266
  } else if (arg === "--name") {
@@ -271,6 +296,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
271
296
  sawRoot = true;
272
297
  }
273
298
  }
299
+ bothOrNeither();
274
300
  return options;
275
301
  }
276
302
 
@@ -660,7 +686,14 @@ const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
660
686
  * the share-key check rather than behind it.
661
687
  */
662
688
  export function isSignInPath(path: string): boolean {
663
- return path.startsWith("/api/v1/auth/") || OAUTH_ROUTE.test(path);
689
+ return (
690
+ path.startsWith("/api/v1/auth/") ||
691
+ // The account's own server list: nixamp.com's API, gated by the session
692
+ // rather than by a share key it has nothing to do with.
693
+ path === "/api/v1/servers" ||
694
+ path.startsWith("/api/v1/servers/") ||
695
+ OAUTH_ROUTE.test(path)
696
+ );
664
697
  }
665
698
 
666
699
  function html(response: ServerResponse, code: number, body: string): void {
@@ -737,8 +770,12 @@ export interface HandlerOptions {
737
770
  accounts?: Accounts;
738
771
  /** Providers to sign in with, and the terminals waiting to be connected. */
739
772
  signIn?: SignIn;
773
+ /** The servers each account runs, on the instance that keeps accounts. */
774
+ servers?: Servers;
740
775
  /** True when this instance is reached over https, for the cookie's Secure. */
741
776
  secureCookies?: boolean;
777
+ /** A certificate and key in PEM, when this server is to speak https itself. */
778
+ tls?: { cert: string; key: string };
742
779
  /**
743
780
  * True when a proxy sits in front, so `x-forwarded-for` names the caller.
744
781
  * False everywhere else on purpose: the header is trivially forged, and
@@ -1213,6 +1250,77 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1213
1250
  return;
1214
1251
  }
1215
1252
 
1253
+ // --- the servers this account runs ----------------------------------
1254
+ //
1255
+ // Kept against the account rather than the machine, so the list reads the
1256
+ // same from the CLI, the PWA and the desktop app -- which is the whole
1257
+ // point: a share link in a terminal you closed is a server you have lost.
1258
+ if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers) {
1259
+ const servers = options.servers;
1260
+ const who = await accounts.whoIs(tokenFrom(request.headers));
1261
+ if (who === null) {
1262
+ json(response, 401, { error: "not signed in" });
1263
+ return;
1264
+ }
1265
+
1266
+ if (path === "/api/v1/servers" && request.method === "GET") {
1267
+ json(response, 200, { servers: await servers.list(who.id) });
1268
+ return;
1269
+ }
1270
+
1271
+ if (path === "/api/v1/servers" && request.method === "POST") {
1272
+ let body: { name?: unknown; url?: unknown; key?: unknown };
1273
+ try {
1274
+ body = JSON.parse(await readBody(request)) as typeof body;
1275
+ } catch {
1276
+ json(response, 400, { error: "bad JSON" });
1277
+ return;
1278
+ }
1279
+ const made = await servers.add(who, {
1280
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1281
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1282
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1283
+ });
1284
+ if (made === null) {
1285
+ json(response, 422, { error: "that needs an http or https address" });
1286
+ return;
1287
+ }
1288
+ json(response, 201, { server: made });
1289
+ return;
1290
+ }
1291
+
1292
+ const id = path.slice("/api/v1/servers/".length);
1293
+ if (id && request.method === "DELETE") {
1294
+ const gone = await servers.remove(who.id, id);
1295
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
1296
+ return;
1297
+ }
1298
+
1299
+ if (id && (request.method === "PATCH" || request.method === "PUT")) {
1300
+ let body: { name?: unknown; url?: unknown; key?: unknown };
1301
+ try {
1302
+ body = JSON.parse(await readBody(request)) as typeof body;
1303
+ } catch {
1304
+ json(response, 400, { error: "bad JSON" });
1305
+ return;
1306
+ }
1307
+ const changed = await servers.update(who.id, id, {
1308
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1309
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1310
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1311
+ });
1312
+ if (changed === null) {
1313
+ json(response, 404, { error: "no such server, or a bad address" });
1314
+ return;
1315
+ }
1316
+ json(response, 200, { server: changed });
1317
+ return;
1318
+ }
1319
+
1320
+ json(response, 405, { error: "GET, POST, PATCH or DELETE" });
1321
+ return;
1322
+ }
1323
+
1216
1324
  // --- tokens a person made on purpose --------------------------------
1217
1325
  if (path === "/api/v1/auth/tokens" || path.startsWith("/api/v1/auth/tokens/")) {
1218
1326
  const who = await accounts.whoIs(tokenFrom(request.headers));
@@ -2201,12 +2309,19 @@ function sendFile(request: IncomingMessage, response: ServerResponse, file: stri
2201
2309
 
2202
2310
  export function createServer(engine: Engine, options: HandlerOptions): Server {
2203
2311
  const handle = createHandler(engine, options);
2204
- return createHttpServer((request, response) => {
2312
+ const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
2205
2313
  handle(request, response).catch(() => {
2206
2314
  if (!response.headersSent) json(response, 500, { error: "server error" });
2207
2315
  else response.end();
2208
2316
  });
2209
- });
2317
+ };
2318
+
2319
+ // https when there is a certificate to serve it with, and the same handler
2320
+ // either way: nothing above this line knows or cares which it got.
2321
+ if (options.tls) {
2322
+ return createHttpsServer({ cert: options.tls.cert, key: options.tls.key }, onRequest) as unknown as Server;
2323
+ }
2324
+ return createHttpServer(onRequest);
2210
2325
  }
2211
2326
 
2212
2327
 
@@ -2434,6 +2549,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2434
2549
  });
2435
2550
  }
2436
2551
 
2552
+ // Read before listening, so a missing or unreadable certificate is a sentence
2553
+ // now rather than a connection that resets later.
2554
+ const tls = options.tlsCert
2555
+ ? (() => {
2556
+ try {
2557
+ return { cert: readFileSync(options.tlsCert, "utf8"), key: readFileSync(options.tlsKey, "utf8") };
2558
+ } catch (error) {
2559
+ throw new Error(`nixamp serve: could not read the certificate: ${(error as Error).message}`);
2560
+ }
2561
+ })()
2562
+ : undefined;
2563
+
2437
2564
  const server = createServer(engine, {
2438
2565
  web,
2439
2566
  media: options.media,
@@ -2449,6 +2576,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2449
2576
  paywall,
2450
2577
  ffmpeg: tools.ffmpeg,
2451
2578
  ffprobe: tools.ffprobe,
2579
+ ...(tls ? { tls } : {}),
2452
2580
  load: (next) => loadSource(tools, next),
2453
2581
  ...(directory ? { directory } : {}),
2454
2582
  ...(follows ? { follows, vapidPublicKey } : {}),
@@ -2470,6 +2598,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2470
2598
  // Whichever providers this deployment was given both halves of, plus
2471
2599
  // the device grant, which is worth having even with no provider at
2472
2600
  // all: a browser already signed in can approve a terminal.
2601
+ // The same pool the follows and reminders use: three small tables in
2602
+ // one database do not want three sets of connections.
2603
+ ...(pool ? { servers: new Servers(pool) } : {}),
2473
2604
  signIn: new SignIn(
2474
2605
  providersFrom(process.env),
2475
2606
  new DeviceGrants(),
@@ -2514,13 +2645,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2514
2645
  // is and nothing local looks public, ask. What comes back is a fact about the
2515
2646
  // router and not about this port -- the port still has to be forwarded -- so
2516
2647
  // it is marked as a guess and everything that prints it says so.
2517
- const localAddresses = reachableAddresses(options.host, port, options.publicUrl);
2648
+ const localAddresses = reachableAddresses(options.host, port, options.publicUrl, tls ? "https" : "http");
2518
2649
  const guessedPublic =
2519
2650
  options.lookup && !options.publicUrl && !localAddresses.some((a) => a.label === "on the internet")
2520
2651
  ? await lookupPublicIp()
2521
2652
  : "";
2522
2653
  const addresses = guessedPublic
2523
- ? reachableAddresses(options.host, port, `http://${guessedPublic.includes(":") ? `[${guessedPublic}]` : guessedPublic}:${port}`)
2654
+ ? reachableAddresses(
2655
+ options.host,
2656
+ port,
2657
+ `${tls ? "https" : "http"}://${guessedPublic.includes(":") ? `[${guessedPublic}]` : guessedPublic}:${port}`,
2658
+ tls ? "https" : "http",
2659
+ )
2524
2660
  : localAddresses;
2525
2661
 
2526
2662
  // Listening on every interface proves the socket is open here and nothing
package/src/servers.ts ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The servers a person runs.
3
+ *
4
+ * A nixamp is a machine somewhere with an address and a key, and until now the
5
+ * only record of one was the share link in whatever terminal printed it. Lose
6
+ * the terminal and you have lost the server: the daemon is still playing, and
7
+ * nothing anywhere can tell you where. This is the list, kept against the
8
+ * account rather than the machine, so it reads the same from the CLI, the PWA
9
+ * and the desktop app.
10
+ *
11
+ * The share key is optional and, when given, is the listen-or-control secret
12
+ * for somebody else's machine sitting in our database. It is stored because a
13
+ * list you cannot click is half a feature, and it is optional because that is
14
+ * a choice which belongs to the person whose server it is.
15
+ */
16
+ import { randomBytes } from "node:crypto";
17
+ import type { Account } from "./accounts.ts";
18
+ import type { Queryable } from "./follows.ts";
19
+
20
+ const TABLE = "nixamp_servers";
21
+
22
+ const SCHEMA = `
23
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
24
+ id TEXT PRIMARY KEY,
25
+ user_id TEXT NOT NULL,
26
+ name TEXT NOT NULL DEFAULT '',
27
+ url TEXT NOT NULL,
28
+ share_key TEXT NOT NULL DEFAULT '',
29
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
30
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
31
+ last_seen_at TIMESTAMPTZ
32
+ );
33
+ CREATE INDEX IF NOT EXISTS ${TABLE}_user ON ${TABLE} (user_id);
34
+ CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_user_url ON ${TABLE} (user_id, url);
35
+ `;
36
+
37
+ export interface ServerEntry {
38
+ id: string;
39
+ name: string;
40
+ url: string;
41
+ /** Empty when the owner chose not to keep it here. */
42
+ key: string;
43
+ createdAt: number;
44
+ updatedAt: number;
45
+ lastSeenAt: number | null;
46
+ }
47
+
48
+ /** What a caller may set. Anything else is the server's business. */
49
+ export interface ServerInput {
50
+ name?: string;
51
+ url?: string;
52
+ key?: string;
53
+ }
54
+
55
+ function asTime(value: unknown): number | null {
56
+ if (value instanceof Date) return value.getTime();
57
+ if (typeof value === "string") {
58
+ const at = Date.parse(value);
59
+ return Number.isNaN(at) ? null : at;
60
+ }
61
+ return typeof value === "number" ? value : null;
62
+ }
63
+
64
+ function toEntry(row: Record<string, unknown>): ServerEntry {
65
+ return {
66
+ id: String(row["id"] ?? ""),
67
+ name: String(row["name"] ?? ""),
68
+ url: String(row["url"] ?? ""),
69
+ key: String(row["share_key"] ?? ""),
70
+ createdAt: asTime(row["created_at"]) ?? 0,
71
+ updatedAt: asTime(row["updated_at"]) ?? 0,
72
+ lastSeenAt: asTime(row["last_seen_at"]),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * An address a browser could open, with nothing else smuggled in.
78
+ *
79
+ * A stored URL is handed back to other clients and put in an href, so a
80
+ * javascript: or data: entry here is a link somebody else clicks. Only http
81
+ * and https, and nothing after the origin: a nixamp is a host and a port.
82
+ */
83
+ export function cleanUrl(value: unknown): string {
84
+ if (typeof value !== "string" || value.trim() === "") return "";
85
+ let parsed: URL;
86
+ try {
87
+ parsed = new URL(value.trim());
88
+ } catch {
89
+ return "";
90
+ }
91
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
92
+ return parsed.origin;
93
+ }
94
+
95
+ /** A name is a label, not an essay, and never empty on screen. */
96
+ export function cleanName(value: unknown, url: string): string {
97
+ const given = typeof value === "string" ? value.trim().slice(0, 60) : "";
98
+ if (given) return given;
99
+ // The host is a better default than "untitled", and it is what somebody
100
+ // would have typed anyway.
101
+ try {
102
+ return new URL(url).host;
103
+ } catch {
104
+ return "a nixamp";
105
+ }
106
+ }
107
+
108
+ export class Servers {
109
+ private ready: Promise<void> | null = null;
110
+
111
+ constructor(private readonly db: Queryable) {}
112
+
113
+ private async ensure(): Promise<void> {
114
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
115
+ await this.ready;
116
+ }
117
+
118
+ async list(userId: string): Promise<ServerEntry[]> {
119
+ await this.ensure();
120
+ const { rows } = await this.db.query(
121
+ `SELECT id, name, url, share_key, created_at, updated_at, last_seen_at FROM ${TABLE}
122
+ WHERE user_id = $1 ORDER BY created_at`,
123
+ [userId],
124
+ );
125
+ return rows.map(toEntry);
126
+ }
127
+
128
+ /**
129
+ * Remember a server, or update the one already at that address.
130
+ *
131
+ * Upserted on the address rather than refused, because the common way to
132
+ * call this twice is a daemon announcing itself after a restart, and that
133
+ * should move the entry forward rather than fail.
134
+ */
135
+ async add(account: Account, input: ServerInput): Promise<ServerEntry | null> {
136
+ const url = cleanUrl(input.url);
137
+ if (!url) return null;
138
+ await this.ensure();
139
+ const { rows } = await this.db.query(
140
+ `INSERT INTO ${TABLE} (id, user_id, name, url, share_key, last_seen_at)
141
+ VALUES ($1, $2, $3, $4, $5, NOW())
142
+ ON CONFLICT (user_id, url) DO UPDATE
143
+ SET name = EXCLUDED.name, share_key = EXCLUDED.share_key,
144
+ updated_at = NOW(), last_seen_at = NOW()
145
+ RETURNING id, name, url, share_key, created_at, updated_at, last_seen_at`,
146
+ [
147
+ randomBytes(8).toString("hex"),
148
+ account.id,
149
+ cleanName(input.name, url),
150
+ url,
151
+ typeof input.key === "string" ? input.key.slice(0, 200) : "",
152
+ ],
153
+ );
154
+ return rows[0] ? toEntry(rows[0]) : null;
155
+ }
156
+
157
+ /** Scoped to the owner, so somebody else's id changes nothing. */
158
+ async update(userId: string, id: string, input: ServerInput): Promise<ServerEntry | null> {
159
+ await this.ensure();
160
+ const url = input.url === undefined ? undefined : cleanUrl(input.url);
161
+ if (input.url !== undefined && !url) return null;
162
+ const { rows } = await this.db.query(
163
+ `UPDATE ${TABLE}
164
+ SET name = COALESCE($3, name),
165
+ url = COALESCE($4, url),
166
+ share_key = COALESCE($5, share_key),
167
+ updated_at = NOW()
168
+ WHERE user_id = $1 AND id = $2
169
+ RETURNING id, name, url, share_key, created_at, updated_at, last_seen_at`,
170
+ [
171
+ userId,
172
+ id,
173
+ input.name === undefined ? null : cleanName(input.name, url ?? ""),
174
+ url ?? null,
175
+ input.key === undefined ? null : input.key.slice(0, 200),
176
+ ],
177
+ );
178
+ return rows[0] ? toEntry(rows[0]) : null;
179
+ }
180
+
181
+ async remove(userId: string, id: string): Promise<boolean> {
182
+ await this.ensure();
183
+ const { rows } = await this.db.query(
184
+ `DELETE FROM ${TABLE} WHERE user_id = $1 AND id = $2 RETURNING id`,
185
+ [userId, id],
186
+ );
187
+ return rows.length > 0;
188
+ }
189
+ }
package/src/session.ts CHANGED
@@ -549,3 +549,113 @@ export async function whoami(fetcher: typeof fetch = fetch): Promise<number> {
549
549
  return 0;
550
550
  }
551
551
  }
552
+
553
+ /**
554
+ * `nixamp server list|add|remove`, the account's own list of machines.
555
+ *
556
+ * A share link printed in a terminal you have since closed is a server you
557
+ * have lost: the daemon is still playing and nothing can tell you where. This
558
+ * keeps the address against the account, so the answer is the same here, in
559
+ * the browser and in the desktop app.
560
+ */
561
+ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Promise<number> {
562
+ const session = readSession();
563
+ if (session === null) {
564
+ console.error("nixamp: not signed in. Try `nixamp login`.");
565
+ return 1;
566
+ }
567
+ const [command = "list", ...rest] = argv;
568
+ const where = `${session.site}/api/v1/servers`;
569
+ const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
570
+ const at = (flag: string): string | undefined => {
571
+ const index = rest.indexOf(flag);
572
+ return index === -1 ? undefined : rest[index + 1];
573
+ };
574
+
575
+ try {
576
+ if (command === "add" || command === "register") {
577
+ // `--here` is the common case: the daemon on this machine, with the
578
+ // address and key it already printed, rather than retyped by hand.
579
+ let url = rest.find((a) => /^https?:\/\//.test(a)) ?? at("--url") ?? "";
580
+ let key = at("--key") ?? "";
581
+ if (rest.includes("--here")) {
582
+ const daemon = await import("./daemon.ts");
583
+ const state = daemon.readState();
584
+ if (state === null) {
585
+ console.error("nixamp: no daemon is running here. Start one, or pass a URL.");
586
+ return 1;
587
+ }
588
+ // The address worth remembering is the one somebody else can open.
589
+ const reachable = state.urls?.find((u) => u.label === "on the internet") ?? state.urls?.[0];
590
+ url = reachable?.url ?? "";
591
+ key = key || (state.key ?? "");
592
+ }
593
+ if (!url) {
594
+ console.error("nixamp: which server? Give a URL, or --here for the daemon on this machine.");
595
+ return 64;
596
+ }
597
+
598
+ const answer = await fetcher(where, {
599
+ method: "POST",
600
+ headers,
601
+ body: JSON.stringify({ url, name: at("--name") ?? "", ...(key ? { key } : {}) }),
602
+ });
603
+ const body = (await answer.json().catch(() => ({}))) as { server?: ServerRow; error?: string };
604
+ if (!answer.ok || !body.server) {
605
+ console.error(`nixamp: ${body.error ?? `could not add it (${answer.status})`}`);
606
+ return 1;
607
+ }
608
+ console.log(`${body.server.id} ${body.server.name} ${body.server.url}`);
609
+ return 0;
610
+ }
611
+
612
+ if (command === "remove" || command === "rm" || command === "forget") {
613
+ const id = rest.find((a) => !a.startsWith("-")) ?? "";
614
+ if (!id) {
615
+ console.error("nixamp: which one? `nixamp server list` shows their ids.");
616
+ return 64;
617
+ }
618
+ const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
619
+ if (!answer.ok) {
620
+ console.error(`nixamp: ${answer.status === 404 ? "no server with that id" : "could not remove it"}`);
621
+ return 1;
622
+ }
623
+ console.log(`Forgot ${id}.`);
624
+ return 0;
625
+ }
626
+
627
+ if (command === "list" || command === "ls") {
628
+ const answer = await fetcher(where, { headers });
629
+ const body = (await answer.json().catch(() => ({}))) as { servers?: ServerRow[]; error?: string };
630
+ if (!answer.ok) {
631
+ console.error(`nixamp: ${body.error ?? `could not list them (${answer.status})`}`);
632
+ return 1;
633
+ }
634
+ const rows = body.servers ?? [];
635
+ if (rows.length === 0) {
636
+ console.log("No servers yet. `nixamp server add --here` remembers the one on this machine.");
637
+ return 0;
638
+ }
639
+ const width = Math.max(...rows.map((row) => row.name.length));
640
+ for (const row of rows) {
641
+ const link = row.key ? `${row.url}/s/${row.key}` : row.url;
642
+ console.log(`${row.id} ${row.name.padEnd(width)} ${link}`);
643
+ }
644
+ return 0;
645
+ }
646
+
647
+ console.error(`nixamp: no such server command: ${command}`);
648
+ return 64;
649
+ } catch (error) {
650
+ console.error(`nixamp: could not reach ${session.site}: ${(error as Error).message}`);
651
+ return 69;
652
+ }
653
+ }
654
+
655
+ /** One row of the account's server list, as the API sends it. */
656
+ interface ServerRow {
657
+ id: string;
658
+ name: string;
659
+ url: string;
660
+ key: string;
661
+ }
package/src/share.ts CHANGED
@@ -132,11 +132,12 @@ export function reachableAddresses(
132
132
  host: string,
133
133
  port: number,
134
134
  publicUrl = "",
135
+ scheme: "http" | "https" = "http",
135
136
  ): { label: string; url: string }[] {
136
137
  const link = (address: string): string => {
137
138
  // A bare IPv6 address needs brackets before it is a URL.
138
139
  const authority = address.includes(":") ? `[${address}]` : address;
139
- return `http://${authority}:${port}`;
140
+ return `${scheme}://${authority}:${port}`;
140
141
  };
141
142
 
142
143
  // An address somebody told us about, because it is one this machine cannot
@@ -162,7 +163,7 @@ export function reachableAddresses(
162
163
  found.sort((x, y) => order[x.kind] - order[y.kind]);
163
164
  return [
164
165
  ...told,
165
- { label: "here", url: `http://localhost:${port}` },
166
+ { label: "here", url: `${scheme}://localhost:${port}` },
166
167
  ...found.map(({ label, url }) => ({ label, url })),
167
168
  ];
168
169
  }
@@ -1 +1 @@
1
- import{t as e}from"./index-CRfW9BIP.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
1
+ import{t as e}from"./index-PgqcCBW4.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};