nixamp 0.5.2 → 0.5.4

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/dist/daemon.d.ts CHANGED
@@ -8,6 +8,25 @@ export interface DaemonState {
8
8
  startedAt: number;
9
9
  /** Where its output went, for when it died and you want to know why. */
10
10
  log: string;
11
+ /**
12
+ * The labelled addresses the server itself worked out, share key not applied.
13
+ *
14
+ * Recorded rather than recomputed because only the server knows them: a
15
+ * daemon bound to every interface has no single host to print, and the
16
+ * public one may be a tunnel it was told about rather than an interface
17
+ * anybody here can see. Absent on a state file written by an older nixamp.
18
+ */
19
+ urls?: {
20
+ label: string;
21
+ url: string;
22
+ }[];
23
+ /** The firewall standing between this port and the rest of the network. */
24
+ firewall?: string | null;
25
+ /**
26
+ * The public address was asked of an outside service rather than found on an
27
+ * interface, so it names the router and not this port.
28
+ */
29
+ guessedPublic?: boolean;
11
30
  }
12
31
  /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
13
32
  export declare function stateDir(): string;
@@ -29,6 +48,17 @@ export declare function status(): {
29
48
  };
30
49
  /** The URL an admin client should talk to. */
31
50
  export declare function daemonUrl(state: DaemonState): string;
51
+ /**
52
+ * What `nixamp daemon start` prints, as lines, so it can be tested without
53
+ * starting a daemon.
54
+ *
55
+ * Every address the server found, not one loopback link: the point of a daemon
56
+ * is the phone in the other room, and 127.0.0.1 is the single address that
57
+ * cannot be handed to anybody. The firewall warning comes with it because the
58
+ * server writes that into a log file nobody reads, not to the person who just
59
+ * typed the command.
60
+ */
61
+ export declare function daemonLines(state: DaemonState): string[];
32
62
  /**
33
63
  * Start one, detached, and wait until it is actually answering before saying
34
64
  * it started. Reporting success and leaving the user to discover a crash in a
package/dist/daemon.js CHANGED
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
+ import { portCommands } from "./share.js";
13
14
  /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
14
15
  export function stateDir() {
15
16
  const base = process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
@@ -63,6 +64,39 @@ export function daemonUrl(state) {
63
64
  const host = state.host === "0.0.0.0" || state.host === "::" ? "127.0.0.1" : state.host;
64
65
  return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
65
66
  }
67
+ /**
68
+ * What `nixamp daemon start` prints, as lines, so it can be tested without
69
+ * starting a daemon.
70
+ *
71
+ * Every address the server found, not one loopback link: the point of a daemon
72
+ * is the phone in the other room, and 127.0.0.1 is the single address that
73
+ * cannot be handed to anybody. The firewall warning comes with it because the
74
+ * server writes that into a log file nobody reads, not to the person who just
75
+ * typed the command.
76
+ */
77
+ export function daemonLines(state) {
78
+ const link = (url) => (state.key ? `${url}/s/${state.key}` : url);
79
+ // A state file written by an older nixamp has no list, so host and port
80
+ // still stand in rather than printing nothing at all.
81
+ const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
82
+ const width = Math.max(...addresses.map((a) => a.label.length), "source".length);
83
+ const lines = [`nixamp daemon running (pid ${state.pid})`];
84
+ for (const { label, url } of addresses)
85
+ lines.push(` ${label.padEnd(width)} ${link(url)}`);
86
+ lines.push(` ${"source".padEnd(width)} ${state.source}`);
87
+ if (state.guessedPublic) {
88
+ lines.push("", " That internet address is this machine's router, not this port.", ` Nothing outside reaches it until ${state.port} is forwarded here.`);
89
+ }
90
+ if (state.firewall) {
91
+ const { open } = portCommands(state.firewall, state.port);
92
+ lines.push("", ` ${state.firewall} is running, so nothing else can reach port ${state.port} yet:`, ` sudo ${open.join(" ")}`, " or `nixamp daemon stop` and start again with --open-port.");
93
+ }
94
+ if (!addresses.some((a) => a.label === "on the internet")) {
95
+ lines.push("", " None of those work from outside this network. If it should:", " nixamp daemon start ... --public-url https://your-tunnel.example.com");
96
+ }
97
+ lines.push("", " nixamp attach the player, in front of it", " nixamp admin who is connected", " nixamp daemon stop when you are done");
98
+ return lines;
99
+ }
66
100
  /**
67
101
  * Start one, detached, and wait until it is actually answering before saying
68
102
  * it started. Reporting success and leaving the user to discover a crash in a
@@ -119,11 +153,15 @@ async function waitForAnnounce(log, timeoutMs) {
119
153
  try {
120
154
  const parsed = JSON.parse(line);
121
155
  if (parsed["nixamp"] === "listening") {
156
+ const urls = parsed["urls"];
122
157
  return {
123
158
  host: String(parsed["host"]),
124
159
  port: Number(parsed["port"]),
125
160
  key: parsed["key"] ?? null,
126
161
  source: String(parsed["source"]),
162
+ ...(Array.isArray(urls) ? { urls: urls } : {}),
163
+ ...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
164
+ ...(parsed["guessedPublic"] === true ? { guessedPublic: true } : {}),
127
165
  };
128
166
  }
129
167
  }
package/dist/main.js CHANGED
@@ -70,6 +70,8 @@ Options for serve:
70
70
  --public-url URL the address this server is reachable at from outside,
71
71
  when that is a tunnel or a forwarded port rather than one of
72
72
  its own interfaces. Also NIXAMP_PUBLIC_URL
73
+ --no-lookup do not ask ipinfo.io what this machine's public address is
74
+ when nothing local looks public
73
75
  --ingest accept a live stream in at POST /api/ingest
74
76
  --rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
75
77
  --rtmp-streams N how many may publish at once (default 3, a port each)
@@ -195,13 +197,8 @@ async function runDaemon(argv) {
195
197
  if (action === "start") {
196
198
  try {
197
199
  const state = await d.start(rest, entry);
198
- console.log(`nixamp daemon running (pid ${state.pid})`);
199
- const url = d.daemonUrl(state);
200
- console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
201
- console.log(` ${state.source}`);
202
- console.log("");
203
- console.log(" nixamp admin who is connected");
204
- console.log(" nixamp daemon stop when you are done");
200
+ for (const line of d.daemonLines(state))
201
+ console.log(line);
205
202
  return 0;
206
203
  }
207
204
  catch (error) {
package/dist/server.d.ts CHANGED
@@ -56,6 +56,12 @@ export interface ServeOptions {
56
56
  * listing is skipped and the printed links only work inside the house.
57
57
  */
58
58
  publicUrl: string;
59
+ /**
60
+ * Ask an outside service what this machine's public address is, when no
61
+ * interface holds one and none was given. Behind NAT that is the only way to
62
+ * learn it, and it is one short request at startup.
63
+ */
64
+ lookup: boolean;
59
65
  /**
60
66
  * Charge for listening once the stream is busy. Off unless asked for, and
61
67
  * useless without somewhere to pay: see NIXAMP_PAY_TO.
package/dist/server.js CHANGED
@@ -34,7 +34,7 @@ import { notifyAll, resendEmail, webPush } from "./notify.js";
34
34
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
35
35
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
36
36
  import { isRemote, playsInBrowser } from "./sources.js";
37
- import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
37
+ import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
38
38
  import { extname, join, normalize, resolve, sep } from "node:path";
39
39
  import { fileURLToPath } from "node:url";
40
40
  import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
@@ -67,6 +67,7 @@ export function parseServeArgs(argv) {
67
67
  publish: "ask",
68
68
  name: "",
69
69
  publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
70
+ lookup: true,
70
71
  x402: false,
71
72
  owner: "",
72
73
  ingest: false,
@@ -127,6 +128,9 @@ export function parseServeArgs(argv) {
127
128
  }
128
129
  options.publicUrl = given.replace(/\/+$/, "");
129
130
  }
131
+ else if (arg === "--no-lookup") {
132
+ options.lookup = false;
133
+ }
130
134
  else if (arg === "--name") {
131
135
  options.name = value();
132
136
  }
@@ -2085,8 +2089,42 @@ export async function serve(argv, version = "0.1.0") {
2085
2089
  return { status: done.status, stdout: done.stdout ?? "" };
2086
2090
  },
2087
2091
  };
2092
+ // The link, not the address. Without the key the address is a 401, so
2093
+ // printing a bare host:port would be printing something that does not work.
2094
+ //
2095
+ // Behind NAT no interface holds the public address, so if nobody said what it
2096
+ // is and nothing local looks public, ask. What comes back is a fact about the
2097
+ // router and not about this port -- the port still has to be forwarded -- so
2098
+ // it is marked as a guess and everything that prints it says so.
2099
+ const localAddresses = reachableAddresses(options.host, port, options.publicUrl);
2100
+ const guessedPublic = options.lookup && !options.publicUrl && !localAddresses.some((a) => a.label === "on the internet")
2101
+ ? await lookupPublicIp()
2102
+ : "";
2103
+ const addresses = guessedPublic
2104
+ ? reachableAddresses(options.host, port, `http://${guessedPublic.includes(":") ? `[${guessedPublic}]` : guessedPublic}:${port}`)
2105
+ : localAddresses;
2106
+ // Listening on every interface proves the socket is open here and nothing
2107
+ // about the path between here and the phone.
2108
+ const listening = options.host === "0.0.0.0" || options.host === "::";
2109
+ const firewall = listening ? firewallInUse(io) : null;
2088
2110
  if (options.announce) {
2089
- console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
2111
+ // Everything `nixamp daemon start` needs to print what this would have
2112
+ // printed. Without the addresses it could only reconstruct host and port,
2113
+ // which for a server bound to every interface means it printed 127.0.0.1 --
2114
+ // an address that works on exactly the machine you are already sitting at.
2115
+ // The firewall matters for the same reason: the warning was going into a
2116
+ // log file nobody reads instead of to the person who just typed the
2117
+ // command.
2118
+ console.log(JSON.stringify({
2119
+ nixamp: "listening",
2120
+ host: options.host,
2121
+ port,
2122
+ key,
2123
+ source: root,
2124
+ urls: addresses,
2125
+ firewall,
2126
+ guessedPublic: guessedPublic !== "",
2127
+ }));
2090
2128
  }
2091
2129
  // The other half of "names now, tags later". It runs while the banner is
2092
2130
  // printed and while the publish prompt waits, and it is deliberately not
@@ -2102,13 +2140,15 @@ export async function serve(argv, version = "0.1.0") {
2102
2140
  }
2103
2141
  console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
2104
2142
  console.log("");
2105
- // The link, not the address. Without the key the address is a 401, so
2106
- // printing a bare host:port would be printing something that does not work.
2107
- const addresses = reachableAddresses(options.host, port, options.publicUrl);
2108
2143
  const width = Math.max(...addresses.map((a) => a.label.length));
2109
2144
  for (const { label, url } of addresses) {
2110
2145
  console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
2111
2146
  }
2147
+ if (guessedPublic) {
2148
+ console.log("");
2149
+ console.log(` That internet address is this machine's router, not this port.`);
2150
+ console.log(` Nothing outside reaches it until ${port} is forwarded here.`);
2151
+ }
2112
2152
  console.log("");
2113
2153
  if (key === null) {
2114
2154
  console.log(" No key: anyone who can reach this port can drive it and hear it.");
@@ -2161,10 +2201,6 @@ export async function serve(argv, version = "0.1.0") {
2161
2201
  }
2162
2202
  if (web === null)
2163
2203
  console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
2164
- // Listening on every interface proves the socket is open here and nothing
2165
- // about the path between here and the phone.
2166
- const listening = options.host === "0.0.0.0" || options.host === "::";
2167
- const firewall = listening ? firewallInUse(io) : null;
2168
2204
  let closePort = null;
2169
2205
  if (firewall !== null) {
2170
2206
  const { open, close } = portCommands(firewall, port);
package/dist/share.d.ts CHANGED
@@ -25,6 +25,20 @@ export declare function keyFrom(request: IncomingMessage, url: URL): string | nu
25
25
  export declare function keyCookie(key: string): string;
26
26
  /** Where an address actually goes, which is not always where you would like. */
27
27
  export declare function classify(address: string): "private" | "cgnat" | "public";
28
+ /** Looks like an address, rather than an error page or a rate-limit notice. */
29
+ export declare function isIpAddress(value: string): boolean;
30
+ /**
31
+ * The address the rest of the world sees this machine as, by asking.
32
+ *
33
+ * Behind NAT no interface holds it, so there is nobody to ask but somebody
34
+ * outside. It is a claim about the router, not about this port: an address
35
+ * discovered this way is only reachable once the router forwards the port to
36
+ * this machine, which is why what prints it says so.
37
+ *
38
+ * Empty string for every failure. A player must not spend its startup waiting
39
+ * on somebody else's web service.
40
+ */
41
+ export declare function lookupPublicIp(send?: typeof fetch, timeoutMs?: number): Promise<string>;
28
42
  /**
29
43
  * The addresses another device could actually reach this machine on, nearest
30
44
  * first. On a server the public one is the point: it is the address a phone
package/dist/share.js CHANGED
@@ -74,6 +74,41 @@ export function classify(address) {
74
74
  return "cgnat";
75
75
  return "public";
76
76
  }
77
+ /** Looks like an address, rather than an error page or a rate-limit notice. */
78
+ export function isIpAddress(value) {
79
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(value)) {
80
+ return value.split(".").every((part) => Number(part) <= 255);
81
+ }
82
+ // Enough of v6 to reject prose: hex groups and colons, nothing else. This is
83
+ // not a validator, it is a guard against printing an error page as an address.
84
+ return value.includes(":") && /^[0-9a-f:]+$/i.test(value);
85
+ }
86
+ /**
87
+ * The address the rest of the world sees this machine as, by asking.
88
+ *
89
+ * Behind NAT no interface holds it, so there is nobody to ask but somebody
90
+ * outside. It is a claim about the router, not about this port: an address
91
+ * discovered this way is only reachable once the router forwards the port to
92
+ * this machine, which is why what prints it says so.
93
+ *
94
+ * Empty string for every failure. A player must not spend its startup waiting
95
+ * on somebody else's web service.
96
+ */
97
+ export async function lookupPublicIp(send = fetch, timeoutMs = 2500) {
98
+ try {
99
+ const answer = await send("https://ipinfo.io/ip", {
100
+ signal: AbortSignal.timeout(timeoutMs),
101
+ headers: { accept: "text/plain", "user-agent": "nixamp" },
102
+ });
103
+ if (!answer.ok)
104
+ return "";
105
+ const found = (await answer.text()).trim();
106
+ return isIpAddress(found) ? found : "";
107
+ }
108
+ catch {
109
+ return "";
110
+ }
111
+ }
77
112
  /**
78
113
  * The addresses another device could actually reach this machine on, nearest
79
114
  * first. On a server the public one is the point: it is the address a phone
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
package/src/daemon.ts CHANGED
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
+ import { type Firewall, portCommands } from "./share.ts";
13
14
 
14
15
  export interface DaemonState {
15
16
  pid: number;
@@ -21,6 +22,22 @@ export interface DaemonState {
21
22
  startedAt: number;
22
23
  /** Where its output went, for when it died and you want to know why. */
23
24
  log: string;
25
+ /**
26
+ * The labelled addresses the server itself worked out, share key not applied.
27
+ *
28
+ * Recorded rather than recomputed because only the server knows them: a
29
+ * daemon bound to every interface has no single host to print, and the
30
+ * public one may be a tunnel it was told about rather than an interface
31
+ * anybody here can see. Absent on a state file written by an older nixamp.
32
+ */
33
+ urls?: { label: string; url: string }[];
34
+ /** The firewall standing between this port and the rest of the network. */
35
+ firewall?: string | null;
36
+ /**
37
+ * The public address was asked of an outside service rather than found on an
38
+ * interface, so it names the router and not this port.
39
+ */
40
+ guessedPublic?: boolean;
24
41
  }
25
42
 
26
43
  /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
@@ -82,6 +99,60 @@ export function daemonUrl(state: DaemonState): string {
82
99
  return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
83
100
  }
84
101
 
102
+ /**
103
+ * What `nixamp daemon start` prints, as lines, so it can be tested without
104
+ * starting a daemon.
105
+ *
106
+ * Every address the server found, not one loopback link: the point of a daemon
107
+ * is the phone in the other room, and 127.0.0.1 is the single address that
108
+ * cannot be handed to anybody. The firewall warning comes with it because the
109
+ * server writes that into a log file nobody reads, not to the person who just
110
+ * typed the command.
111
+ */
112
+ export function daemonLines(state: DaemonState): string[] {
113
+ const link = (url: string): string => (state.key ? `${url}/s/${state.key}` : url);
114
+ // A state file written by an older nixamp has no list, so host and port
115
+ // still stand in rather than printing nothing at all.
116
+ const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
117
+ const width = Math.max(...addresses.map((a) => a.label.length), "source".length);
118
+
119
+ const lines = [`nixamp daemon running (pid ${state.pid})`];
120
+ for (const { label, url } of addresses) lines.push(` ${label.padEnd(width)} ${link(url)}`);
121
+ lines.push(` ${"source".padEnd(width)} ${state.source}`);
122
+
123
+ if (state.guessedPublic) {
124
+ lines.push(
125
+ "",
126
+ " That internet address is this machine's router, not this port.",
127
+ ` Nothing outside reaches it until ${state.port} is forwarded here.`,
128
+ );
129
+ }
130
+ if (state.firewall) {
131
+ const { open } = portCommands(state.firewall as Firewall, state.port);
132
+ lines.push(
133
+ "",
134
+ ` ${state.firewall} is running, so nothing else can reach port ${state.port} yet:`,
135
+ ` sudo ${open.join(" ")}`,
136
+ " or `nixamp daemon stop` and start again with --open-port.",
137
+ );
138
+ }
139
+ if (!addresses.some((a) => a.label === "on the internet")) {
140
+ lines.push(
141
+ "",
142
+ " None of those work from outside this network. If it should:",
143
+ " nixamp daemon start ... --public-url https://your-tunnel.example.com",
144
+ );
145
+ }
146
+
147
+ lines.push(
148
+ "",
149
+ " nixamp attach the player, in front of it",
150
+ " nixamp admin who is connected",
151
+ " nixamp daemon stop when you are done",
152
+ );
153
+ return lines;
154
+ }
155
+
85
156
  /**
86
157
  * Start one, detached, and wait until it is actually answering before saying
87
158
  * it started. Reporting success and leaving the user to discover a crash in a
@@ -142,11 +213,15 @@ async function waitForAnnounce(
142
213
  try {
143
214
  const parsed = JSON.parse(line) as { nixamp?: string } & Record<string, unknown>;
144
215
  if (parsed["nixamp"] === "listening") {
216
+ const urls = parsed["urls"];
145
217
  return {
146
218
  host: String(parsed["host"]),
147
219
  port: Number(parsed["port"]),
148
220
  key: (parsed["key"] as string | null) ?? null,
149
221
  source: String(parsed["source"]),
222
+ ...(Array.isArray(urls) ? { urls: urls as { label: string; url: string }[] } : {}),
223
+ ...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
224
+ ...(parsed["guessedPublic"] === true ? { guessedPublic: true } : {}),
150
225
  };
151
226
  }
152
227
  } catch {
package/src/main.ts CHANGED
@@ -94,6 +94,8 @@ Options for serve:
94
94
  --public-url URL the address this server is reachable at from outside,
95
95
  when that is a tunnel or a forwarded port rather than one of
96
96
  its own interfaces. Also NIXAMP_PUBLIC_URL
97
+ --no-lookup do not ask ipinfo.io what this machine's public address is
98
+ when nothing local looks public
97
99
  --ingest accept a live stream in at POST /api/ingest
98
100
  --rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
99
101
  --rtmp-streams N how many may publish at once (default 3, a port each)
@@ -224,13 +226,7 @@ async function runDaemon(argv: string[]): Promise<number> {
224
226
  if (action === "start") {
225
227
  try {
226
228
  const state = await d.start(rest, entry);
227
- console.log(`nixamp daemon running (pid ${state.pid})`);
228
- const url = d.daemonUrl(state);
229
- console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
230
- console.log(` ${state.source}`);
231
- console.log("");
232
- console.log(" nixamp admin who is connected");
233
- console.log(" nixamp daemon stop when you are done");
229
+ for (const line of d.daemonLines(state)) console.log(line);
234
230
  return 0;
235
231
  } catch (error) {
236
232
  console.error((error as Error).message);
package/src/server.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  keyCookie,
62
62
  keyFrom,
63
63
  keysMatch,
64
+ lookupPublicIp,
64
65
  newKey,
65
66
  portCommands,
66
67
  reachableAddresses,
@@ -128,6 +129,12 @@ export interface ServeOptions {
128
129
  * listing is skipped and the printed links only work inside the house.
129
130
  */
130
131
  publicUrl: string;
132
+ /**
133
+ * Ask an outside service what this machine's public address is, when no
134
+ * interface holds one and none was given. Behind NAT that is the only way to
135
+ * learn it, and it is one short request at startup.
136
+ */
137
+ lookup: boolean;
131
138
  /**
132
139
  * Charge for listening once the stream is busy. Off unless asked for, and
133
140
  * useless without somewhere to pay: see NIXAMP_PAY_TO.
@@ -178,6 +185,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
178
185
  publish: "ask",
179
186
  name: "",
180
187
  publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
188
+ lookup: true,
181
189
  x402: false,
182
190
  owner: "",
183
191
  ingest: false,
@@ -226,6 +234,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
226
234
  throw new Error("nixamp serve: --public-url must be a URL, e.g. https://nixamp.example.com");
227
235
  }
228
236
  options.publicUrl = given.replace(/\/+$/, "");
237
+ } else if (arg === "--no-lookup") {
238
+ options.lookup = false;
229
239
  } else if (arg === "--name") {
230
240
  options.name = value();
231
241
  } else if (arg === "--owner") {
@@ -2390,8 +2400,47 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2390
2400
  },
2391
2401
  };
2392
2402
 
2403
+ // The link, not the address. Without the key the address is a 401, so
2404
+ // printing a bare host:port would be printing something that does not work.
2405
+ //
2406
+ // Behind NAT no interface holds the public address, so if nobody said what it
2407
+ // is and nothing local looks public, ask. What comes back is a fact about the
2408
+ // router and not about this port -- the port still has to be forwarded -- so
2409
+ // it is marked as a guess and everything that prints it says so.
2410
+ const localAddresses = reachableAddresses(options.host, port, options.publicUrl);
2411
+ const guessedPublic =
2412
+ options.lookup && !options.publicUrl && !localAddresses.some((a) => a.label === "on the internet")
2413
+ ? await lookupPublicIp()
2414
+ : "";
2415
+ const addresses = guessedPublic
2416
+ ? reachableAddresses(options.host, port, `http://${guessedPublic.includes(":") ? `[${guessedPublic}]` : guessedPublic}:${port}`)
2417
+ : localAddresses;
2418
+
2419
+ // Listening on every interface proves the socket is open here and nothing
2420
+ // about the path between here and the phone.
2421
+ const listening = options.host === "0.0.0.0" || options.host === "::";
2422
+ const firewall = listening ? firewallInUse(io) : null;
2423
+
2393
2424
  if (options.announce) {
2394
- console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
2425
+ // Everything `nixamp daemon start` needs to print what this would have
2426
+ // printed. Without the addresses it could only reconstruct host and port,
2427
+ // which for a server bound to every interface means it printed 127.0.0.1 --
2428
+ // an address that works on exactly the machine you are already sitting at.
2429
+ // The firewall matters for the same reason: the warning was going into a
2430
+ // log file nobody reads instead of to the person who just typed the
2431
+ // command.
2432
+ console.log(
2433
+ JSON.stringify({
2434
+ nixamp: "listening",
2435
+ host: options.host,
2436
+ port,
2437
+ key,
2438
+ source: root,
2439
+ urls: addresses,
2440
+ firewall,
2441
+ guessedPublic: guessedPublic !== "",
2442
+ }),
2443
+ );
2395
2444
  }
2396
2445
 
2397
2446
  // The other half of "names now, tags later". It runs while the banner is
@@ -2410,14 +2459,17 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2410
2459
  console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
2411
2460
  console.log("");
2412
2461
 
2413
- // The link, not the address. Without the key the address is a 401, so
2414
- // printing a bare host:port would be printing something that does not work.
2415
- const addresses = reachableAddresses(options.host, port, options.publicUrl);
2416
2462
  const width = Math.max(...addresses.map((a) => a.label.length));
2417
2463
  for (const { label, url } of addresses) {
2418
2464
  console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
2419
2465
  }
2420
2466
 
2467
+ if (guessedPublic) {
2468
+ console.log("");
2469
+ console.log(` That internet address is this machine's router, not this port.`);
2470
+ console.log(` Nothing outside reaches it until ${port} is forwarded here.`);
2471
+ }
2472
+
2421
2473
  console.log("");
2422
2474
  if (key === null) {
2423
2475
  console.log(" No key: anyone who can reach this port can drive it and hear it.");
@@ -2469,10 +2521,6 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2469
2521
  }
2470
2522
  if (web === null) console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
2471
2523
 
2472
- // Listening on every interface proves the socket is open here and nothing
2473
- // about the path between here and the phone.
2474
- const listening = options.host === "0.0.0.0" || options.host === "::";
2475
- const firewall = listening ? firewallInUse(io) : null;
2476
2524
  let closePort: (() => void) | null = null;
2477
2525
 
2478
2526
  if (firewall !== null) {
package/src/share.ts CHANGED
@@ -87,6 +87,41 @@ export function classify(address: string): "private" | "cgnat" | "public" {
87
87
  return "public";
88
88
  }
89
89
 
90
+ /** Looks like an address, rather than an error page or a rate-limit notice. */
91
+ export function isIpAddress(value: string): boolean {
92
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(value)) {
93
+ return value.split(".").every((part) => Number(part) <= 255);
94
+ }
95
+ // Enough of v6 to reject prose: hex groups and colons, nothing else. This is
96
+ // not a validator, it is a guard against printing an error page as an address.
97
+ return value.includes(":") && /^[0-9a-f:]+$/i.test(value);
98
+ }
99
+
100
+ /**
101
+ * The address the rest of the world sees this machine as, by asking.
102
+ *
103
+ * Behind NAT no interface holds it, so there is nobody to ask but somebody
104
+ * outside. It is a claim about the router, not about this port: an address
105
+ * discovered this way is only reachable once the router forwards the port to
106
+ * this machine, which is why what prints it says so.
107
+ *
108
+ * Empty string for every failure. A player must not spend its startup waiting
109
+ * on somebody else's web service.
110
+ */
111
+ export async function lookupPublicIp(send: typeof fetch = fetch, timeoutMs = 2500): Promise<string> {
112
+ try {
113
+ const answer = await send("https://ipinfo.io/ip", {
114
+ signal: AbortSignal.timeout(timeoutMs),
115
+ headers: { accept: "text/plain", "user-agent": "nixamp" },
116
+ });
117
+ if (!answer.ok) return "";
118
+ const found = (await answer.text()).trim();
119
+ return isIpAddress(found) ? found : "";
120
+ } catch {
121
+ return "";
122
+ }
123
+ }
124
+
90
125
  /**
91
126
  * The addresses another device could actually reach this machine on, nearest
92
127
  * first. On a server the public one is the point: it is the address a phone
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1788936569431";
2
+ const CACHE = "nixamp-1788937565929";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",