nixamp 0.43.1 → 0.45.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.
Files changed (57) hide show
  1. package/dist/guide-rooms.d.ts +90 -0
  2. package/dist/guide-rooms.js +256 -0
  3. package/dist/library.d.ts +8 -0
  4. package/dist/library.js +1 -0
  5. package/dist/pit-footer.d.ts +3 -0
  6. package/dist/pit-footer.js +40 -0
  7. package/dist/server.d.ts +6 -0
  8. package/dist/server.js +75 -5
  9. package/dist/station-cli.js +19 -0
  10. package/dist/station-guide.d.ts +24 -3
  11. package/dist/station-guide.js +60 -8
  12. package/dist/station-page.d.ts +5 -0
  13. package/dist/station-page.js +15 -0
  14. package/dist/station-routes.d.ts +2 -0
  15. package/dist/station-routes.js +3 -1
  16. package/dist/station-usage.d.ts +1 -1
  17. package/dist/station-usage.js +2 -0
  18. package/package.json +1 -1
  19. package/src/guide-rooms.ts +296 -0
  20. package/src/library.ts +5 -0
  21. package/src/pit-footer.ts +40 -0
  22. package/src/server.ts +79 -5
  23. package/src/station-cli.ts +21 -0
  24. package/src/station-guide.ts +68 -10
  25. package/src/station-page.ts +16 -0
  26. package/src/station-routes.ts +5 -1
  27. package/src/station-usage.ts +2 -0
  28. package/web/dist/assets/{ar-CnqhCJJo.js → ar-CV8jyOug.js} +1 -1
  29. package/web/dist/assets/{cs-Bsyx7wxR.js → cs-_qnbzDla.js} +1 -1
  30. package/web/dist/assets/{da-B4kIRtMO.js → da-B5FYL3Hl.js} +1 -1
  31. package/web/dist/assets/{de-CUIKrC06.js → de-CBwqcL_O.js} +1 -1
  32. package/web/dist/assets/{es-xgQHSTbS.js → es-Cqpz6_wS.js} +1 -1
  33. package/web/dist/assets/{fi-BarRr2r9.js → fi-BCckNeQZ.js} +1 -1
  34. package/web/dist/assets/{fr-CkdhPlV8.js → fr-BtTpQJDS.js} +1 -1
  35. package/web/dist/assets/{hi-Bmz_R4Qe.js → hi-kHrHp-LW.js} +1 -1
  36. package/web/dist/assets/{hu-Buques8B.js → hu-uG0HRO6c.js} +1 -1
  37. package/web/dist/assets/{id-DI6eGXlp.js → id-hoEx4_vN.js} +1 -1
  38. package/web/dist/assets/{it-DoPVwxbT.js → it-Bx4j5Tgv.js} +1 -1
  39. package/web/dist/assets/main-B__9rtDL.js +22 -0
  40. package/web/dist/assets/{nl-Bi2G8eau.js → nl-BZCUFXO1.js} +1 -1
  41. package/web/dist/assets/{radio-Cvjck2GD.js → radio-BU-XH1pD.js} +1 -1
  42. package/web/dist/assets/{ru-gdX1kz2l.js → ru-CnhmLVES.js} +1 -1
  43. package/web/dist/assets/{station-B9ORrH8G.js → station-D3ICZ3fF.js} +1 -1
  44. package/web/dist/assets/styles-dKtpEefI.css +1 -0
  45. package/web/dist/assets/{sv-DETdNoY-.js → sv-DgLuYxwC.js} +1 -1
  46. package/web/dist/assets/{uk-BiRmx495.js → uk-DSdKgI41.js} +1 -1
  47. package/web/dist/assets/{vi-C018OY69.js → vi-BR1oDS1f.js} +1 -1
  48. package/web/dist/assets/watch-DHAXWXqJ.js +1 -0
  49. package/web/dist/assets/{zh-HZ6x11wW.js → zh-Dcq7yWXP.js} +1 -1
  50. package/web/dist/index.html +40 -5
  51. package/web/dist/radio.html +3 -3
  52. package/web/dist/station.html +3 -3
  53. package/web/dist/sw.js +25 -23
  54. package/web/dist/watch.html +30 -0
  55. package/web/dist/assets/main-VepIOCc0.js +0 -22
  56. package/web/dist/assets/styles-qC62Gona.css +0 -1
  57. /package/web/dist/assets/{styles-D70tSrIe.js → styles-DmL68lWt.js} +0 -0
@@ -0,0 +1,90 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { GuideEntry } from "./station-guide.ts";
3
+ /** Why a room may be broadcast, the same five reasons a station gives. */
4
+ export declare const ROOM_BASES: readonly ["owned", "permission", "licensed", "creative-commons", "public-domain"];
5
+ export type RoomBasis = typeof ROOM_BASES[number];
6
+ export interface GuideRoom {
7
+ id: string;
8
+ /** The room's stream address, with its listen key. */
9
+ play: string;
10
+ name: string;
11
+ kind: "audio" | "video";
12
+ basis: RoomBasis;
13
+ note: string;
14
+ /** The nixamp.com account that published it ("" for this server's owner). */
15
+ by: string;
16
+ publishedAt: number;
17
+ }
18
+ /** Rooms one member may have on the guide at once, and rooms on it in all. */
19
+ export declare const ROOMS_PER_MEMBER = 5;
20
+ export declare const ROOMS_MAX = 200;
21
+ /**
22
+ * A room's address taken apart, or null when it is not one: https (http only
23
+ * for a loopback address, for tests), a channel or the live stream, a key,
24
+ * and nothing else in the query.
25
+ */
26
+ export declare function roomAddress(input: string): {
27
+ origin: string;
28
+ channel: string | null;
29
+ key: string;
30
+ play: string;
31
+ } | null;
32
+ /** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
33
+ export declare function watchBase(fallback: string, env?: Record<string, string | undefined>): string;
34
+ export declare const watchPage: (base: string, play: string) => string;
35
+ export declare class RoomStore {
36
+ private readonly file;
37
+ private rooms;
38
+ constructor(file: string);
39
+ static fileFor(stateDir: string, port: number): string;
40
+ list(): GuideRoom[];
41
+ get(id: string): GuideRoom | undefined;
42
+ /** Publishes a room, or refreshes the one already published at that address. */
43
+ put(room: Omit<GuideRoom, "id" | "publishedAt">): GuideRoom;
44
+ remove(id: string): boolean;
45
+ private save;
46
+ }
47
+ export interface RoomState {
48
+ /** Whether the room's server says it is on the air. */
49
+ live: boolean;
50
+ name: string;
51
+ kind: "audio" | "video";
52
+ nowPlaying: {
53
+ title: string;
54
+ artist: string;
55
+ } | null;
56
+ }
57
+ /** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
58
+ export declare class RoomProbe {
59
+ private readonly fetcher;
60
+ private readonly refuse;
61
+ private cache;
62
+ /**
63
+ * `refuse` names why an address may not be fetched from this machine (a
64
+ * private or loopback network), or "": a member chooses the address, and
65
+ * this server must not become their way into its own network.
66
+ */
67
+ constructor(fetcher?: typeof fetch, refuse?: (link: string) => Promise<string>);
68
+ state(play: string, fresh?: boolean): Promise<RoomState | null>;
69
+ private ask;
70
+ }
71
+ /** The rooms as guide cards: the page to open, on the air or not, and what is on. */
72
+ export declare function roomEntries(store: RoomStore, probe: RoomProbe, base: string): Promise<GuideEntry[]>;
73
+ export interface RoomContext {
74
+ store: RoomStore;
75
+ probe: RoomProbe;
76
+ /** The page base for a room's card. */
77
+ base: string;
78
+ /** Whether the request holds this server's control key (or is its signed-in owner). */
79
+ canControl: boolean;
80
+ /** The nixamp.com account behind the request's session, or "". */
81
+ member: () => Promise<string>;
82
+ json: (response: ServerResponse, status: number, body: unknown) => void;
83
+ readBody: (request: IncomingMessage) => Promise<unknown>;
84
+ /** Refused by the caller's rate limit, or null. */
85
+ throttle?: () => {
86
+ retryAfter: number;
87
+ } | null;
88
+ }
89
+ /** Answers /api/v1/guide/rooms; false when the path is not one. */
90
+ export declare function handleRooms(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomContext): Promise<boolean>;
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Live rooms on the station guide.
3
+ *
4
+ * A room is a channel, or the live stream, of any nixamp server: the address
5
+ * a Share or a "?play=" link carries, `https://host:port/api/channels/<id>?k=KEY`
6
+ * or `https://host:port/api/live?k=KEY`. A nixamp.com member publishes one with
7
+ * a rights basis, as a station is published; the guide lists it beside the
8
+ * stations, and its card opens /watch?play=<that address>, which plays it as
9
+ * a film or as radio, whichever it carries.
10
+ *
11
+ * Nothing here plays anything: the room's own server does, and this only asks
12
+ * it, now and then, whether the room is on the air and what is on it.
13
+ */
14
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { randomBytes } from "node:crypto";
17
+ import { readConfig } from "./library.js";
18
+ /** Why a room may be broadcast, the same five reasons a station gives. */
19
+ export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"];
20
+ /** Rooms one member may have on the guide at once, and rooms on it in all. */
21
+ export const ROOMS_PER_MEMBER = 5;
22
+ export const ROOMS_MAX = 200;
23
+ const STATE_TTL_MS = 15_000;
24
+ const PROBE_TIMEOUT_MS = 4_000;
25
+ /**
26
+ * A room's address taken apart, or null when it is not one: https (http only
27
+ * for a loopback address, for tests), a channel or the live stream, a key,
28
+ * and nothing else in the query.
29
+ */
30
+ export function roomAddress(input) {
31
+ let url;
32
+ try {
33
+ url = new URL(input.trim());
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost";
39
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
40
+ return null;
41
+ if (url.username || url.password || url.hash)
42
+ return null;
43
+ const channel = /^\/api\/channels\/([A-Za-z0-9._~-]{1,120})\/?$/.exec(url.pathname);
44
+ const live = /^\/api\/live\/?$/.test(url.pathname);
45
+ if (!channel && !live)
46
+ return null;
47
+ const key = url.searchParams.get("k") ?? "";
48
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(key) || [...url.searchParams.keys()].some((name) => name !== "k"))
49
+ return null;
50
+ const path = channel ? `/api/channels/${channel[1]}` : "/api/live";
51
+ return { origin: url.origin, channel: channel ? channel[1] : null, key, play: `${url.origin}${path}?k=${key}` };
52
+ }
53
+ /** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
54
+ export function watchBase(fallback, env = process.env) {
55
+ const saved = (() => { try {
56
+ return readConfig().watchBase ?? "";
57
+ }
58
+ catch {
59
+ return "";
60
+ } })();
61
+ const chosen = (env["NIXAMP_WATCH_BASE"] || saved || fallback).trim().replace(/\/+$/, "");
62
+ return /^https?:\/\/[^\s"<>]+$/.test(chosen) ? chosen : fallback;
63
+ }
64
+ export const watchPage = (base, play) => `${base}/watch?play=${encodeURIComponent(play)}`;
65
+ export class RoomStore {
66
+ file;
67
+ rooms = [];
68
+ constructor(file) {
69
+ this.file = file;
70
+ try {
71
+ if (existsSync(file)) {
72
+ const saved = JSON.parse(readFileSync(file, "utf8"));
73
+ this.rooms = (saved.rooms ?? []).filter((one) => roomAddress(one.play) !== null);
74
+ }
75
+ }
76
+ catch {
77
+ // Unreadable is empty; the next write replaces it.
78
+ }
79
+ }
80
+ static fileFor(stateDir, port) {
81
+ return join(stateDir, `guide-rooms-${port}.json`);
82
+ }
83
+ list() {
84
+ return [...this.rooms];
85
+ }
86
+ get(id) {
87
+ return this.rooms.find((one) => one.id === id);
88
+ }
89
+ /** Publishes a room, or refreshes the one already published at that address. */
90
+ put(room) {
91
+ const existing = this.rooms.find((one) => one.play === room.play);
92
+ const saved = { ...room, id: existing?.id ?? randomBytes(6).toString("base64url"), publishedAt: Date.now() };
93
+ this.rooms = [...this.rooms.filter((one) => one.play !== room.play), saved];
94
+ this.save();
95
+ return saved;
96
+ }
97
+ remove(id) {
98
+ const before = this.rooms.length;
99
+ this.rooms = this.rooms.filter((one) => one.id !== id);
100
+ if (this.rooms.length === before)
101
+ return false;
102
+ this.save();
103
+ return true;
104
+ }
105
+ save() {
106
+ writeFileSync(`${this.file}.part`, `${JSON.stringify({ version: 1, rooms: this.rooms }, null, 2)}\n`);
107
+ renameSync(`${this.file}.part`, this.file);
108
+ }
109
+ }
110
+ /** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
111
+ export class RoomProbe {
112
+ fetcher;
113
+ refuse;
114
+ cache = new Map();
115
+ /**
116
+ * `refuse` names why an address may not be fetched from this machine (a
117
+ * private or loopback network), or "": a member chooses the address, and
118
+ * this server must not become their way into its own network.
119
+ */
120
+ constructor(fetcher = fetch, refuse = async () => "") {
121
+ this.fetcher = fetcher;
122
+ this.refuse = refuse;
123
+ }
124
+ async state(play, fresh = false) {
125
+ const cached = this.cache.get(play);
126
+ if (!fresh && cached && Date.now() - cached.at < STATE_TTL_MS)
127
+ return cached.state;
128
+ const state = await this.ask(play).catch(() => null);
129
+ this.cache.set(play, { at: Date.now(), state });
130
+ if (this.cache.size > 500)
131
+ this.cache.delete(this.cache.keys().next().value);
132
+ return state;
133
+ }
134
+ async ask(play) {
135
+ const address = roomAddress(play);
136
+ if (!address || (await this.refuse(address.play)) !== "")
137
+ return null;
138
+ if (address.channel !== null) {
139
+ const answer = await this.fetcher(`${address.origin}/api/channels?k=${address.key}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), redirect: "error" });
140
+ if (!answer.ok)
141
+ return null;
142
+ const body = (await answer.json());
143
+ const row = (body.channels ?? []).find((one) => one.id === address.channel);
144
+ if (!row)
145
+ return null;
146
+ const tags = row.codecs?.tags;
147
+ return {
148
+ live: row.live !== false,
149
+ name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
150
+ kind: row.kind === "video" ? "video" : "audio",
151
+ nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
152
+ };
153
+ }
154
+ // The live stream: on the air when it answers with media.
155
+ const controller = new AbortController();
156
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
157
+ try {
158
+ const answer = await this.fetcher(address.play, { signal: controller.signal, redirect: "error" });
159
+ const type = answer.headers.get("content-type") ?? "";
160
+ controller.abort();
161
+ if (!answer.ok)
162
+ return null;
163
+ return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
164
+ }
165
+ finally {
166
+ clearTimeout(timer);
167
+ }
168
+ }
169
+ }
170
+ /** The rooms as guide cards: the page to open, on the air or not, and what is on. */
171
+ export async function roomEntries(store, probe, base) {
172
+ return Promise.all(store.list().map(async (room) => {
173
+ const state = await probe.state(room.play);
174
+ return {
175
+ id: room.id,
176
+ name: room.name,
177
+ kind: state?.kind ?? room.kind,
178
+ group: "rooms",
179
+ state: state?.live ? "live" : "offline",
180
+ page: watchPage(base, room.play),
181
+ nowPlaying: state?.nowPlaying ?? null,
182
+ };
183
+ }));
184
+ }
185
+ /** Answers /api/v1/guide/rooms; false when the path is not one. */
186
+ export async function handleRooms(request, response, path, ctx) {
187
+ if (path !== "/api/v1/guide/rooms" && !path.startsWith("/api/v1/guide/rooms/"))
188
+ return false;
189
+ const method = request.method ?? "GET";
190
+ if (path === "/api/v1/guide/rooms" && (method === "GET" || method === "HEAD")) {
191
+ ctx.json(response, 200, { rooms: await roomEntries(ctx.store, ctx.probe, ctx.base) });
192
+ return true;
193
+ }
194
+ const by = ctx.canControl ? "" : await ctx.member();
195
+ if (!ctx.canControl && by === "") {
196
+ ctx.json(response, 401, { error: "sign in to nixamp.com to publish a live room", code: "sign_in" });
197
+ return true;
198
+ }
199
+ const refused = ctx.throttle?.();
200
+ if (refused) {
201
+ response.setHeader("retry-after", String(refused.retryAfter));
202
+ ctx.json(response, 429, { error: "too many changes; wait a moment", code: "throttled" });
203
+ return true;
204
+ }
205
+ if (path === "/api/v1/guide/rooms" && method === "POST") {
206
+ const body = (await ctx.readBody(request).catch(() => null));
207
+ const address = roomAddress(typeof body?.play === "string" ? body.play : "");
208
+ if (!address) {
209
+ ctx.json(response, 400, { error: "play must be a nixamp room's stream address: https://host/api/channels/<id>?k=KEY", code: "bad_address" });
210
+ return true;
211
+ }
212
+ const basis = ROOM_BASES.find((one) => one === body?.basis);
213
+ if (!basis || body?.agree !== true) {
214
+ ctx.json(response, 400, { error: "say why it may be broadcast (owned, permission, licensed, creative-commons or public-domain) and agree", code: "basis_required" });
215
+ return true;
216
+ }
217
+ const state = await ctx.probe.state(address.play, true);
218
+ if (!state) {
219
+ ctx.json(response, 404, { error: "that room is not on its server, or the key does not open it", code: "not_found" });
220
+ return true;
221
+ }
222
+ const all = ctx.store.list();
223
+ const mine = all.filter((one) => one.by === by && one.play !== address.play);
224
+ if (by !== "" && mine.length >= ROOMS_PER_MEMBER) {
225
+ ctx.json(response, 409, { error: `a member may have ${ROOMS_PER_MEMBER} rooms on the guide; take one off first`, code: "too_many" });
226
+ return true;
227
+ }
228
+ if (!all.some((one) => one.play === address.play) && all.length >= ROOMS_MAX) {
229
+ ctx.json(response, 409, { error: "the guide is full", code: "full" });
230
+ return true;
231
+ }
232
+ const taken = all.find((one) => one.play === address.play);
233
+ if (taken && taken.by !== by && !ctx.canControl) {
234
+ ctx.json(response, 409, { error: "somebody else published that room", code: "taken" });
235
+ return true;
236
+ }
237
+ const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
238
+ const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
239
+ const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
240
+ ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: watchPage(ctx.base, room.play) });
241
+ return true;
242
+ }
243
+ const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
244
+ if (one && method === "DELETE") {
245
+ const room = ctx.store.get(one[1]);
246
+ if (!room || (!ctx.canControl && room.by !== by)) {
247
+ ctx.json(response, 404, { error: "no such room of yours on the guide", code: "not_found" });
248
+ return true;
249
+ }
250
+ ctx.store.remove(room.id);
251
+ ctx.json(response, 200, { removed: room.id });
252
+ return true;
253
+ }
254
+ ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
255
+ return true;
256
+ }
package/dist/library.d.ts CHANGED
@@ -9,6 +9,14 @@ export interface Config {
9
9
  guideHosts?: string[];
10
10
  /** Other servers (https base addresses) whose public, listed stations this guide shows too. */
11
11
  guidePeers?: string[];
12
+ /** Where the guide links a published live room's page (https://live.streaming.pit.world); this server's own address when unset. */
13
+ watchBase?: string;
14
+ /** crawlproof on the guide: tracker project id (site), ad slot and format (`nixamp station guide-ads`). */
15
+ guideAds?: {
16
+ site?: string;
17
+ slot?: string;
18
+ format?: "banner_300x250" | "text_link";
19
+ };
12
20
  /** Station limits kept across restarts (station-metering.ts). */
13
21
  limits?: Record<string, unknown>;
14
22
  /** Proxies whose X-Forwarded-For is believed for listener shares (`nixamp station shares trust ADDRESS`). */
package/dist/library.js CHANGED
@@ -35,6 +35,7 @@ export function readConfig() {
35
35
  ...(typeof record["limits"] === "object" && record["limits"] !== null ? { limits: record["limits"] } : {}),
36
36
  ...(Array.isArray(record["trustedProxies"]) ? { trustedProxies: record["trustedProxies"].filter((one) => typeof one === "string") } : {}),
37
37
  ...(Array.isArray(record["guideHosts"]) ? { guideHosts: record["guideHosts"].filter((one) => typeof one === "string") } : {}),
38
+ ...(typeof record["guideAds"] === "object" && record["guideAds"] !== null ? { guideAds: record["guideAds"] } : {}),
38
39
  ...(Array.isArray(record["guidePeers"]) ? { guidePeers: record["guidePeers"].filter((one) => typeof one === "string") } : {}),
39
40
  };
40
41
  }
@@ -0,0 +1,3 @@
1
+ export declare function refreshPitFooter(): Promise<void>;
2
+ /** The page with the footer before its last </body>, or the page unchanged. */
3
+ export declare function withPitFooter(page: string): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The pit.world global footer (site name, search, ring, publish), fetched on
3
+ * the server and appended as the last thing in <body> of the nixamp.com pages.
4
+ *
5
+ * A module-level cache: the last good copy is served, and one older than an
6
+ * hour is refreshed in the background (3s timeout). A failed refresh keeps the
7
+ * old copy; with no copy at all the page goes out unchanged, so pit.world being
8
+ * down never breaks a page.
9
+ */
10
+ const PIT_FOOTER_URL = "https://pit.world/snippets/footer.html?site=nixamp.com";
11
+ const MAX_AGE_MS = 60 * 60 * 1000;
12
+ let cached = "";
13
+ let fetchedAt = 0;
14
+ let inflight = null;
15
+ export function refreshPitFooter() {
16
+ inflight ??= fetch(PIT_FOOTER_URL, { signal: AbortSignal.timeout(3000) })
17
+ .then(async (res) => {
18
+ const text = res.ok ? await res.text() : "";
19
+ if (!text.includes("data-pit-footer"))
20
+ throw new Error(`pit footer: HTTP ${res.status}`);
21
+ cached = text;
22
+ fetchedAt = Date.now();
23
+ })
24
+ .catch(() => {
25
+ fetchedAt = Date.now() - MAX_AGE_MS + 60_000; // try again in a minute
26
+ })
27
+ .finally(() => {
28
+ inflight = null;
29
+ });
30
+ return inflight;
31
+ }
32
+ /** The page with the footer before its last </body>, or the page unchanged. */
33
+ export function withPitFooter(page) {
34
+ if (Date.now() - fetchedAt > MAX_AGE_MS)
35
+ void refreshPitFooter();
36
+ if (!cached || page.includes("data-pit-footer"))
37
+ return page;
38
+ const at = page.lastIndexOf("</body>");
39
+ return at < 0 ? page : page.slice(0, at) + cached + page.slice(at);
40
+ }
package/dist/server.d.ts CHANGED
@@ -27,6 +27,7 @@ import { Importer } from "./handoff.ts";
27
27
  import { AiRadio } from "./ai-radio.ts";
28
28
  import { ListenerShares } from "./listener-shares.ts";
29
29
  import { Support, SupportSubscriptions } from "./support.ts";
30
+ import { RoomProbe, RoomStore } from "./guide-rooms.ts";
30
31
  import { PeerGuides } from "./station-guide.ts";
31
32
  import { StationRunner } from "./station-runner.ts";
32
33
  import { StationStore, type Station } from "./stations.ts";
@@ -616,6 +617,11 @@ export interface HandlerOptions {
616
617
  trustedProxies?: () => Set<string>;
617
618
  /** Other servers' public stations, for this guide to list beside its own. */
618
619
  peers?: PeerGuides;
620
+ /** Live rooms members published to this guide, and how to ask a room's server what is on it. */
621
+ rooms?: {
622
+ store: RoomStore;
623
+ probe: RoomProbe;
624
+ };
619
625
  };
620
626
  web: string | null;
621
627
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
package/dist/server.js CHANGED
@@ -68,8 +68,10 @@ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions,
68
68
  import { LyricsFinder } from "./lyrics.js";
69
69
  import { LyricsPublisher } from "./lyrics-publish.js";
70
70
  import { aiKey } from "./ai-keys.js";
71
- import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
72
- import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
71
+ import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.js";
72
+ import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.js";
73
+ import { withPitFooter } from "./pit-footer.js";
74
+ import { guideAds, guideCsp, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
73
75
  import { StationRunner } from "./station-runner.js";
74
76
  import { StationError, StationStore, channelOf, stationOfChannel } from "./stations.js";
75
77
  import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv } from "./station-metering.js";
@@ -2170,6 +2172,10 @@ export function createHandler(engine, options) {
2170
2172
  !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2171
2173
  // A station's short link, which sends the browser to wherever it plays.
2172
2174
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2175
+ // A live room's page: the room's own key travels in ?play=, checked by its server.
2176
+ !(path === "/watch" && Boolean(options.stations?.rooms)) &&
2177
+ // Live rooms on the guide: listed for anybody, published by a member (guide-rooms.ts).
2178
+ !((path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/")) && Boolean(options.stations?.rooms)) &&
2173
2179
  !STATION_PAGE.test(path) &&
2174
2180
  !isPublicAsset &&
2175
2181
  !isGuidePage &&
@@ -2345,11 +2351,15 @@ export function createHandler(engine, options) {
2345
2351
  });
2346
2352
  const own = new Set(entries.map((one) => one.page));
2347
2353
  const others = options.stations.peers ? (await options.stations.peers.entries()).filter((one) => !own.has(one.page)) : [];
2348
- const page = renderGuide([...entries, ...others], options.stations.publicBase?.() ?? "");
2354
+ const rooms = options.stations.rooms
2355
+ ? await roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
2356
+ : [];
2357
+ const ads = guideAds();
2358
+ const page = renderGuide([...rooms, ...entries, ...others], options.stations.publicBase?.() ?? "", ads);
2349
2359
  response.writeHead(200, {
2350
2360
  "content-type": "text/html; charset=utf-8",
2351
2361
  "cache-control": "no-cache",
2352
- "content-security-policy": GUIDE_CSP,
2362
+ "content-security-policy": guideCsp(ads),
2353
2363
  "referrer-policy": "no-referrer",
2354
2364
  "x-content-type-options": "nosniff",
2355
2365
  });
@@ -2358,6 +2368,44 @@ export function createHandler(engine, options) {
2358
2368
  }
2359
2369
  // The page a station's link opens. The same bytes for every station: it
2360
2370
  // reads its id and token from its own address.
2371
+ // A live room's page: /watch?play=<a nixamp server's channel or live
2372
+ // stream address>. The built page plays it in @profullstack/player, as a
2373
+ // film or as radio; nothing about the room passes through here.
2374
+ if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
2375
+ const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html")) : null;
2376
+ if (!built) {
2377
+ json(response, 404, { error: "this server was started without its web pages" });
2378
+ return;
2379
+ }
2380
+ response.writeHead(200, {
2381
+ "content-type": "text/html; charset=utf-8",
2382
+ "cache-control": "no-cache",
2383
+ "content-security-policy": WATCH_APP_CSP,
2384
+ "referrer-policy": "no-referrer",
2385
+ "x-content-type-options": "nosniff",
2386
+ });
2387
+ response.end(request.method === "HEAD" ? undefined : built);
2388
+ return;
2389
+ }
2390
+ if (options.stations?.rooms && (path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/"))) {
2391
+ const control = await controlOf();
2392
+ const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
2393
+ const rooms = options.stations.rooms;
2394
+ await handleRooms(request, response, path, {
2395
+ store: rooms.store,
2396
+ probe: rooms.probe,
2397
+ base: watchBase(options.stations.publicBase?.() || `https://${request.headers.host ?? "localhost"}`),
2398
+ canControl: control.allowed,
2399
+ member: async () => (session && options.owner ? (await options.owner.accountFor(session)) || "" : ""),
2400
+ json,
2401
+ readBody,
2402
+ throttle: () => {
2403
+ const verdict = guard.check(`rooms:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
2404
+ return verdict.ok ? null : { retryAfter: verdict.retryAfter };
2405
+ },
2406
+ });
2407
+ return;
2408
+ }
2361
2409
  if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
2362
2410
  // The built page plays in @profullstack/player; the inline one is for a
2363
2411
  // server started without the web build.
@@ -2474,6 +2522,9 @@ export function createHandler(engine, options) {
2474
2522
  ...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
2475
2523
  ...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
2476
2524
  ...(options.stations.peers ? { peers: () => options.stations.peers.entries() } : {}),
2525
+ ...(options.stations.rooms ? {
2526
+ rooms: () => roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${scheme}://${request.headers.host ?? "localhost"}`)),
2527
+ } : {}),
2477
2528
  throttle: () => {
2478
2529
  const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
2479
2530
  return verdict.ok ? null : { retryAfter: verdict.retryAfter };
@@ -5958,7 +6009,24 @@ export function createHandler(engine, options) {
5958
6009
  }
5959
6010
  const shell = subject ? readIfPossible(file) : null;
5960
6011
  if (subject && shell !== null) {
5961
- html(response, 200, joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
6012
+ html(response, 200, requestHost === "nixamp.com" ? withPitFooter(joinDocument(shell, subject, eventSite ?? "https://nixamp.com")) : joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
6013
+ return;
6014
+ }
6015
+ }
6016
+ // nixamp.com's own pages (the app shell, the radio console) carry the
6017
+ // pit.world global footer, appended on the way out. A local install
6018
+ // serving the same files on its own host is left alone.
6019
+ if (requestHost === "nixamp.com" && file.endsWith(".html") && (request.method === "GET" || request.method === "HEAD")) {
6020
+ const shell = readIfPossible(file);
6021
+ if (shell !== null) {
6022
+ const page = withPitFooter(shell);
6023
+ response.writeHead(200, {
6024
+ ...CORS,
6025
+ "content-type": "text/html; charset=utf-8",
6026
+ "content-length": Buffer.byteLength(page),
6027
+ "cache-control": "no-cache",
6028
+ });
6029
+ response.end(request.method === "HEAD" ? undefined : page);
5962
6030
  return;
5963
6031
  }
5964
6032
  }
@@ -6560,6 +6628,8 @@ export async function serve(argv, version = "0.1.0") {
6560
6628
  const listed = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean).slice(0, 100) : [];
6561
6629
  return [...new Set([...guidePeers(), ...listed])];
6562
6630
  }),
6631
+ // Live rooms published to this guide; the probe never reaches this machine's own network.
6632
+ rooms: { store: new RoomStore(RoomStore.fileFor(stateDir(), options.port)), probe: new RoomProbe(fetch, (link) => refusedDestination(link)) },
6563
6633
  };
6564
6634
  // Music handed over from BitTorrented, copied into a folder of our own.
6565
6635
  const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
@@ -207,6 +207,25 @@ export async function station(argv) {
207
207
  console.log(hosts.size ? `The guide is the front page for: ${[...hosts].join(", ")}` : "No host shows the guide as its front page (it is still at /guide).");
208
208
  return 0;
209
209
  }
210
+ case "guide-ads": {
211
+ // crawlproof analytics + an ad unit on the guide; read by the daemon on every request.
212
+ const { readConfig, writeConfig } = await import("./library.js");
213
+ const { cleanGuideAds } = await import("./station-guide.js");
214
+ const raw = argv.includes("--off") ? {} : { ...(readConfig().guideAds ?? {}), ...Object.fromEntries(["site", "slot", "format"].flatMap((name) => { const value = flag(argv, `--${name}`); return value === undefined ? [] : [[name, value]]; })) };
215
+ const next = cleanGuideAds(raw);
216
+ for (const name of ["site", "slot"]) {
217
+ if (typeof raw[name] === "string" && raw[name] !== "" && !next[name]) {
218
+ console.error(`--${name} is a crawlproof id (letters, digits and dashes)`);
219
+ return 2;
220
+ }
221
+ }
222
+ writeConfig({ guideAds: next });
223
+ console.log([
224
+ next.site ? `Tracker crawlproof project ${next.site}` : "Tracker off",
225
+ next.slot ? `Ad crawlproof slot ${next.slot} (${next.format ?? "banner_300x250"})` : "Ad off",
226
+ ].join("\n"));
227
+ return 0;
228
+ }
210
229
  case "guide-peer": {
211
230
  // Another server whose public, listed stations this guide shows too.
212
231
  const { readConfig, writeConfig } = await import("./library.js");
@@ -11,8 +11,8 @@ export interface GuideEntry {
11
11
  id?: string;
12
12
  name: string;
13
13
  kind: "audio" | "video";
14
- /** ai, shared, radio or tv; from kind when absent. */
15
- group?: "ai" | "shared" | "radio" | "tv";
14
+ /** rooms (live rooms, src/guide-rooms.ts), ai, shared, radio or tv; from kind when absent. */
15
+ group?: "rooms" | "ai" | "shared" | "radio" | "tv";
16
16
  state: string;
17
17
  page: string;
18
18
  nowPlaying: {
@@ -27,9 +27,30 @@ export interface GuideEntry {
27
27
  * gateway that cannot run its script against this server (the Moshpit's
28
28
  * /n/<name>), and for a crawler. Everything from a station is escaped.
29
29
  */
30
- export declare function renderGuide(entries: GuideEntry[], apiBase?: string): string;
30
+ export declare function renderGuide(entries: GuideEntry[], apiBase?: string, ads?: GuideAds): string;
31
31
  export declare const GUIDE_HTML: string;
32
+ /**
33
+ * The guide's policy. With crawlproof configured (guideAds below) its tracker
34
+ * script and its ad frame are allowed from crawlproof.com and nothing else
35
+ * changes; the tracker reports by fetch, which connect-src https: already allows.
36
+ */
37
+ export declare function guideCsp(ads?: GuideAds): string;
32
38
  export declare const GUIDE_CSP: string;
39
+ /** crawlproof analytics and an ad on the guide: a project id (tracker data-site) and an ad slot. */
40
+ export interface GuideAds {
41
+ site?: string;
42
+ slot?: string;
43
+ format?: "banner_300x250" | "text_link";
44
+ }
45
+ /** A setting as the guide may use it: ids that are ids, a format it knows, or nothing. */
46
+ export declare function cleanGuideAds(value: unknown): GuideAds;
47
+ /**
48
+ * crawlproof on the guide: `guideAds` in config.json (`nixamp station guide-ads
49
+ * --site ID --slot ID`), each overridden by NIXAMP_GUIDE_CRAWLPROOF_SITE /
50
+ * NIXAMP_GUIDE_AD_SLOT / NIXAMP_GUIDE_AD_FORMAT. Unset is off: a guide on
51
+ * somebody's own machine carries nobody's tracker.
52
+ */
53
+ export declare function guideAds(env?: Record<string, string | undefined>): GuideAds;
33
54
  /**
34
55
  * The hosts whose front page is the guide: `guideHosts` in config.json
35
56
  * (`nixamp station guide-host NAME`, which survives restarts and updates),