nixamp 0.2.0 → 0.4.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 (69) hide show
  1. package/README.md +171 -0
  2. package/dist/accounts.d.ts +54 -0
  3. package/dist/accounts.js +160 -0
  4. package/dist/broadcast.d.ts +96 -0
  5. package/dist/broadcast.js +193 -0
  6. package/dist/channels.d.ts +94 -0
  7. package/dist/channels.js +235 -0
  8. package/dist/connections.d.ts +6 -0
  9. package/dist/connections.js +13 -0
  10. package/dist/directory.d.ts +186 -0
  11. package/dist/directory.js +275 -0
  12. package/dist/durable.d.ts +70 -0
  13. package/dist/durable.js +156 -0
  14. package/dist/follows.d.ts +92 -0
  15. package/dist/follows.js +248 -0
  16. package/dist/ingest.d.ts +80 -0
  17. package/dist/ingest.js +252 -0
  18. package/dist/main.js +21 -0
  19. package/dist/manage.js +2 -1
  20. package/dist/notify.d.ts +83 -0
  21. package/dist/notify.js +126 -0
  22. package/dist/optin.d.ts +37 -0
  23. package/dist/optin.js +122 -0
  24. package/dist/owner.d.ts +53 -0
  25. package/dist/owner.js +96 -0
  26. package/dist/partyline.d.ts +259 -0
  27. package/dist/partyline.js +616 -0
  28. package/dist/paywall.d.ts +60 -0
  29. package/dist/paywall.js +162 -0
  30. package/dist/playlist.js +5 -0
  31. package/dist/publish.d.ts +57 -0
  32. package/dist/publish.js +106 -0
  33. package/dist/rtmp-in.d.ts +22 -0
  34. package/dist/rtmp-in.js +79 -0
  35. package/dist/server.d.ts +94 -0
  36. package/dist/server.js +1158 -12
  37. package/dist/session.d.ts +29 -0
  38. package/dist/session.js +184 -0
  39. package/dist/share.d.ts +26 -0
  40. package/dist/share.js +31 -0
  41. package/package.json +8 -2
  42. package/src/accounts.ts +193 -0
  43. package/src/broadcast.ts +264 -0
  44. package/src/channels.ts +281 -0
  45. package/src/connections.ts +13 -0
  46. package/src/directory.ts +362 -0
  47. package/src/durable.ts +215 -0
  48. package/src/follows.ts +307 -0
  49. package/src/ingest.ts +297 -0
  50. package/src/main.ts +21 -0
  51. package/src/manage.ts +2 -1
  52. package/src/notify.ts +217 -0
  53. package/src/optin.ts +128 -0
  54. package/src/owner.ts +113 -0
  55. package/src/partyline.ts +742 -0
  56. package/src/paywall.ts +198 -0
  57. package/src/playlist.ts +5 -0
  58. package/src/publish.ts +137 -0
  59. package/src/rtmp-in.ts +90 -0
  60. package/src/server.ts +1304 -12
  61. package/src/session.ts +209 -0
  62. package/src/share.ts +40 -0
  63. package/src/types/auth-system.d.ts +77 -0
  64. package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
  65. package/web/dist/assets/index-qRguFskX.js +1 -0
  66. package/web/dist/index.html +62 -6
  67. package/web/dist/install.sh +82 -0
  68. package/web/dist/sw.js +45 -3
  69. package/web/dist/assets/index-Dhja5wxB.js +0 -1
@@ -0,0 +1,275 @@
1
+ import { randomInt } from "node:crypto";
2
+ /**
3
+ * The public directory.
4
+ *
5
+ * A nixamp that agrees to be listed announces itself to nixamp.com every so
6
+ * often and is forgotten when it stops. There is no database behind it: an
7
+ * entry lives for a few minutes and a heartbeat renews it, so a restart of the
8
+ * directory costs one heartbeat rather than a migration, and a stream that
9
+ * dies falls out of the list without anyone having to notice.
10
+ *
11
+ * What is published is the *listen* link. The control key never leaves the
12
+ * machine it was minted on.
13
+ */
14
+ /** How long an entry survives without a heartbeat. */
15
+ export const TTL_MS = 4 * 60 * 1000;
16
+ /** How often a publisher renews. Comfortably inside the TTL. */
17
+ export const HEARTBEAT_MS = 90 * 1000;
18
+ export const DEFAULT_DIRECTORY = "https://nixamp.com";
19
+ /** How long an ended stream is still worth telling a caller about. */
20
+ export const ENDED_TTL_MS = 24 * 60 * 60 * 1000;
21
+ const MAX_NAME = 60;
22
+ const MAX_TRACK = 120;
23
+ /** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
24
+ export function clean(value, max) {
25
+ if (typeof value !== "string")
26
+ return "";
27
+ // Control characters include the escape that starts an ANSI sequence, and
28
+ // this text is rendered in a terminal as well as a browser.
29
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
30
+ }
31
+ /**
32
+ * A URL we are willing to list. It has to be somewhere a browser can go, and
33
+ * it must not be a loopback or link-local address: those are only reachable
34
+ * from the machine that published them, so listing one is an entry nobody but
35
+ * the publisher can ever open.
36
+ */
37
+ export function publishable(raw) {
38
+ let url;
39
+ try {
40
+ url = new URL(raw);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ if (url.protocol !== "http:" && url.protocol !== "https:")
46
+ return null;
47
+ const host = url.hostname.replace(/^\[|\]$/g, "");
48
+ if (host === "localhost" || host === "::1" || host.endsWith(".localhost"))
49
+ return null;
50
+ if (/^127\./.test(host) || /^169\.254\./.test(host))
51
+ return null;
52
+ return url;
53
+ }
54
+ export function parseAnnouncement(input) {
55
+ if (typeof input !== "object" || input === null)
56
+ return null;
57
+ const record = input;
58
+ const url = typeof record["url"] === "string" ? record["url"] : "";
59
+ const listen = publishable(url);
60
+ if (listen === null)
61
+ return null;
62
+ // The audio address has to be the same server as the listen link. This one
63
+ // is played into a telephone call that somebody pays for by the minute, and
64
+ // an announcement that could name any address on the internet could point
65
+ // the phone line at any of them. Same origin, or we do not take it.
66
+ const offered = typeof record["audio"] === "string" ? record["audio"] : "";
67
+ const parsed = offered ? publishable(offered) : null;
68
+ const audio = parsed !== null && parsed.origin === listen.origin ? offered : "";
69
+ const name = clean(record["name"], MAX_NAME);
70
+ const tracks = Number(record["tracks"]);
71
+ return {
72
+ ...(typeof record["id"] === "string" ? { id: clean(record["id"], 40) } : {}),
73
+ name: name || "a nixamp",
74
+ url,
75
+ ...(audio ? { audio } : {}),
76
+ tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
77
+ nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
78
+ };
79
+ }
80
+ /**
81
+ * The registry. In memory on purpose: see the note at the top of the file.
82
+ * One entry per URL, so a publisher restarting does not leave a ghost of
83
+ * itself behind next to the entry that replaced it.
84
+ */
85
+ export class Directory {
86
+ ttl;
87
+ now;
88
+ randomCode;
89
+ onLive;
90
+ items = new Map();
91
+ /** Streams that stopped, so the phone line can say when. */
92
+ ended = new Map();
93
+ sequence = 0;
94
+ /**
95
+ * Somewhere to echo the ended list, so it survives a restart.
96
+ *
97
+ * Attached after construction rather than taken as a constructor argument:
98
+ * this is a mirror, not a dependency, and the directory works exactly as it
99
+ * did without one.
100
+ */
101
+ mirror = null;
102
+ /** Start echoing ended streams somewhere durable. */
103
+ persistTo(mirror) {
104
+ this.mirror = mirror;
105
+ }
106
+ /**
107
+ * Put back what a previous process knew.
108
+ *
109
+ * Only fills gaps: anything already here was announced since we started and
110
+ * is newer than a row written before the restart.
111
+ */
112
+ seedEnded(items) {
113
+ for (const item of items) {
114
+ if (!this.ended.has(item.id) && !this.items.has(item.id))
115
+ this.ended.set(item.id, item);
116
+ }
117
+ }
118
+ constructor(ttl = TTL_MS, now = Date.now,
119
+ /** Injected so a test can make a code predictable rather than guess it. */
120
+ randomCode = () => String(randomInt(0, 1_000_000)).padStart(6, "0"),
121
+ /**
122
+ * Called when a stream starts, and only then.
123
+ *
124
+ * A publisher announces every ninety seconds for as long as it is up, so
125
+ * "announced" is not "went live" -- telling followers on every heartbeat
126
+ * would be telling them forty times an hour. This fires on the transition
127
+ * and not on the renewals that follow it.
128
+ */
129
+ onLive = () => { }) {
130
+ this.ttl = ttl;
131
+ this.now = now;
132
+ this.randomCode = randomCode;
133
+ this.onLive = onLive;
134
+ }
135
+ announce(announcement, ownerId = "") {
136
+ this.sweep();
137
+ const existing = [...this.items.values()].find((item) => item.url === announcement.url);
138
+ // A stream coming back after a gap keeps the code it had, so a caller who
139
+ // was told "call back later" can key the same six digits and get through.
140
+ const previously = existing ?? this.endedByUrl(announcement.url);
141
+ const id = previously?.id ?? `s${++this.sequence}${this.now().toString(36)}`;
142
+ const code = previously?.code ?? this.freeCode();
143
+ if (this.ended.has(id)) {
144
+ this.ended.delete(id);
145
+ this.mirror?.drop(id);
146
+ }
147
+ const listing = {
148
+ id,
149
+ code,
150
+ name: announcement.name,
151
+ // A returning stream keeps the owner it had, so a heartbeat that omits
152
+ // it cannot orphan a listing people are following.
153
+ ownerId: ownerId || existing?.ownerId || previously?.ownerId || "",
154
+ url: announcement.url,
155
+ // A heartbeat that omits it keeps what we had, the same as the owner: an
156
+ // older publisher renewing an entry should not blank the address the
157
+ // phone line is playing from.
158
+ audio: announcement.audio ?? existing?.audio ?? "",
159
+ tracks: announcement.tracks,
160
+ nowPlaying: announcement.nowPlaying,
161
+ updatedAt: this.now(),
162
+ // A stream that never stopped keeps its original start. One that did
163
+ // starts again now, because that is what a caller is being told about.
164
+ startedAt: existing?.startedAt ?? this.now(),
165
+ };
166
+ this.items.set(id, listing);
167
+ // The transition, not the heartbeat: existing means it was already live.
168
+ if (existing === undefined)
169
+ this.onLive(listing);
170
+ return listing;
171
+ }
172
+ withdraw(id) {
173
+ const item = this.items.get(id);
174
+ if (item !== undefined)
175
+ this.remember(item);
176
+ this.items.delete(id);
177
+ }
178
+ list() {
179
+ this.sweep();
180
+ return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
181
+ }
182
+ /** The live stream on this code, if there is one. */
183
+ liveByCode(code) {
184
+ this.sweep();
185
+ return [...this.items.values()].find((item) => item.code === code);
186
+ }
187
+ /**
188
+ * Streams that stopped recently, most recent first.
189
+ *
190
+ * Kept for the phone line, which has to say when a stream ended -- but they
191
+ * answer a second question the live list cannot: who is there to follow.
192
+ * Following exists to hear about broadcasts you would otherwise miss, and a
193
+ * directory that only lists what is on can only be used to follow somebody
194
+ * during a broadcast you did not miss.
195
+ */
196
+ recentlyEnded() {
197
+ this.sweep();
198
+ const live = new Set([...this.items.values()].map((item) => item.id));
199
+ return [...this.ended.values()]
200
+ .filter((item) => !live.has(item.id))
201
+ .sort((a, b) => b.endedAt - a.endedAt);
202
+ }
203
+ /** The name last used by an account, live or recently ended. */
204
+ nameOf(ownerId) {
205
+ if (!ownerId)
206
+ return "";
207
+ this.sweep();
208
+ const live = [...this.items.values()].find((item) => item.ownerId === ownerId);
209
+ if (live)
210
+ return live.name;
211
+ const ended = [...this.ended.values()]
212
+ .filter((item) => item.ownerId === ownerId)
213
+ .sort((a, b) => b.endedAt - a.endedAt)[0];
214
+ return ended?.name ?? "";
215
+ }
216
+ /** Whether this account is streaming right now. */
217
+ isLive(ownerId) {
218
+ if (!ownerId)
219
+ return false;
220
+ this.sweep();
221
+ return [...this.items.values()].some((item) => item.ownerId === ownerId);
222
+ }
223
+ /** The stream that used to be on this code, if it stopped recently. */
224
+ endedByCode(code) {
225
+ this.sweep();
226
+ return [...this.ended.values()].find((item) => item.code === code);
227
+ }
228
+ endedByUrl(url) {
229
+ return [...this.ended.values()].find((item) => item.url === url);
230
+ }
231
+ remember(item) {
232
+ const record = {
233
+ id: item.id,
234
+ code: item.code,
235
+ name: item.name,
236
+ ownerId: item.ownerId,
237
+ url: item.url,
238
+ nowPlaying: item.nowPlaying,
239
+ startedAt: item.startedAt,
240
+ endedAt: item.updatedAt,
241
+ };
242
+ this.ended.set(item.id, record);
243
+ this.mirror?.save(record);
244
+ }
245
+ /** A code no live and no recently-ended stream is using. */
246
+ freeCode() {
247
+ for (let tries = 0; tries < 40; tries += 1) {
248
+ const code = this.randomCode();
249
+ if (code.length !== 6)
250
+ continue;
251
+ const taken = [...this.items.values()].some((i) => i.code === code) ||
252
+ [...this.ended.values()].some((i) => i.code === code);
253
+ if (!taken)
254
+ return code;
255
+ }
256
+ return "";
257
+ }
258
+ /** Forget anything that stopped renewing, keeping a note of when it did. */
259
+ sweep() {
260
+ const cutoff = this.now() - this.ttl;
261
+ for (const [id, item] of this.items) {
262
+ if (item.updatedAt < cutoff) {
263
+ this.remember(item);
264
+ this.items.delete(id);
265
+ }
266
+ }
267
+ const forget = this.now() - ENDED_TTL_MS;
268
+ for (const [id, item] of this.ended) {
269
+ if (item.endedAt < forget) {
270
+ this.ended.delete(id);
271
+ this.mirror?.drop(id);
272
+ }
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The two pieces of state that were promises, and were only in memory.
3
+ *
4
+ * Most of nixamp is deliberately ephemeral. The directory is a four-minute TTL
5
+ * and a heartbeat, because a stream that stops is a stream nobody is hearing,
6
+ * and a restart costs one heartbeat rather than a migration. That reasoning is
7
+ * right for what is on. It is wrong for two things that outlived their stream
8
+ * on purpose:
9
+ *
10
+ * A caller who pressed 1 was told "we will text you when they are live
11
+ * again". That subscription lived in a Map, so a deploy dropped it and the
12
+ * text never came -- and nothing anywhere said so. A promise made on a phone
13
+ * call and quietly forgotten is worse than never offering it.
14
+ *
15
+ * A stream that ended is what the phone line reads back ("ended at 9:27 PM
16
+ * Pacific") and what the directory offers to follow when nobody is on. After
17
+ * a deploy the code a caller had been told to key would find nothing and
18
+ * open an empty room instead.
19
+ *
20
+ * This is a mirror rather than a replacement. The in-memory maps stay exactly
21
+ * as they were -- so every caller stays synchronous and every existing test
22
+ * still describes the same object -- and each write is echoed here, with the
23
+ * contents read back once at boot. The cost of that choice is that two
24
+ * instances would each hold their own copy; nixamp.com runs one, and a second
25
+ * would need this to become the source of truth rather than the mirror.
26
+ */
27
+ import type { Queryable } from "./follows.ts";
28
+ export interface StoredEnded {
29
+ id: string;
30
+ code: string;
31
+ name: string;
32
+ ownerId: string;
33
+ url: string;
34
+ nowPlaying: string;
35
+ startedAt: number;
36
+ endedAt: number;
37
+ }
38
+ export declare class Durable {
39
+ private readonly db;
40
+ private readonly onEvent;
41
+ private ready;
42
+ constructor(db: Queryable, onEvent?: (message: string) => void);
43
+ private ensure;
44
+ /**
45
+ * Nothing here is worth taking a request down for.
46
+ *
47
+ * These are all mirror writes: the in-memory copy is what the request is
48
+ * answered from, so a database that is briefly unreachable should cost the
49
+ * durability and not the feature.
50
+ */
51
+ private quietly;
52
+ saveEnded(stream: StoredEnded): Promise<void>;
53
+ /** A stream that came back, or one old enough to forget. */
54
+ dropEnded(id: string): Promise<void>;
55
+ /** What ended since `since`, oldest first so replaying it rebuilds the order. */
56
+ loadEnded(since: number): Promise<StoredEnded[]>;
57
+ addReminder(code: string, phone: string): Promise<void>;
58
+ /**
59
+ * Take everyone waiting on a code, and stop them waiting, in one statement.
60
+ *
61
+ * `DELETE ... RETURNING` rather than a select and then a delete: the rows
62
+ * come back as they are removed, so two goings-live at once cannot both read
63
+ * the same list and text everybody twice.
64
+ */
65
+ takeReminders(code: string): Promise<string[]>;
66
+ /** Everyone waiting, by code, to seed a process that has just started. */
67
+ loadReminders(): Promise<Map<string, Set<string>>>;
68
+ /** Forget what is too old to be worth telling anybody about. */
69
+ sweep(endedBefore: number, remindersBefore: Date): Promise<void>;
70
+ }
@@ -0,0 +1,156 @@
1
+ const SCHEMA = `
2
+ CREATE TABLE IF NOT EXISTS ended_streams (
3
+ id TEXT PRIMARY KEY,
4
+ code TEXT NOT NULL,
5
+ name TEXT NOT NULL DEFAULT '',
6
+ owner_id TEXT NOT NULL DEFAULT '',
7
+ url TEXT NOT NULL DEFAULT '',
8
+ now_playing TEXT NOT NULL DEFAULT '',
9
+ started_at BIGINT NOT NULL,
10
+ ended_at BIGINT NOT NULL
11
+ );
12
+ CREATE INDEX IF NOT EXISTS ended_streams_code ON ended_streams (code);
13
+
14
+ CREATE TABLE IF NOT EXISTS stream_reminders (
15
+ code TEXT NOT NULL,
16
+ phone TEXT NOT NULL,
17
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
18
+ PRIMARY KEY (code, phone)
19
+ );
20
+ `;
21
+ export class Durable {
22
+ db;
23
+ onEvent;
24
+ ready = null;
25
+ constructor(db, onEvent = () => { }) {
26
+ this.db = db;
27
+ this.onEvent = onEvent;
28
+ }
29
+ async ensure() {
30
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
31
+ await this.ready;
32
+ }
33
+ /**
34
+ * Nothing here is worth taking a request down for.
35
+ *
36
+ * These are all mirror writes: the in-memory copy is what the request is
37
+ * answered from, so a database that is briefly unreachable should cost the
38
+ * durability and not the feature.
39
+ */
40
+ async quietly(what, run) {
41
+ try {
42
+ await this.ensure();
43
+ await run();
44
+ }
45
+ catch (error) {
46
+ this.onEvent(` ${what} did not persist: ${error.message}`);
47
+ }
48
+ }
49
+ async saveEnded(stream) {
50
+ await this.quietly("an ended stream", () => this.db.query(`INSERT INTO ended_streams
51
+ (id, code, name, owner_id, url, now_playing, started_at, ended_at)
52
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
53
+ ON CONFLICT (id) DO UPDATE
54
+ SET code = EXCLUDED.code,
55
+ name = EXCLUDED.name,
56
+ owner_id = EXCLUDED.owner_id,
57
+ url = EXCLUDED.url,
58
+ now_playing = EXCLUDED.now_playing,
59
+ started_at = EXCLUDED.started_at,
60
+ ended_at = EXCLUDED.ended_at`, [
61
+ stream.id,
62
+ stream.code,
63
+ stream.name,
64
+ stream.ownerId,
65
+ stream.url,
66
+ stream.nowPlaying,
67
+ stream.startedAt,
68
+ stream.endedAt,
69
+ ]));
70
+ }
71
+ /** A stream that came back, or one old enough to forget. */
72
+ async dropEnded(id) {
73
+ await this.quietly("dropping an ended stream", () => this.db.query("DELETE FROM ended_streams WHERE id = $1", [id]));
74
+ }
75
+ /** What ended since `since`, oldest first so replaying it rebuilds the order. */
76
+ async loadEnded(since) {
77
+ try {
78
+ await this.ensure();
79
+ const { rows } = await this.db.query("SELECT * FROM ended_streams WHERE ended_at >= $1 ORDER BY ended_at", [since]);
80
+ return rows.map((r) => ({
81
+ id: String(r["id"] ?? ""),
82
+ code: String(r["code"] ?? ""),
83
+ name: String(r["name"] ?? ""),
84
+ ownerId: String(r["owner_id"] ?? ""),
85
+ url: String(r["url"] ?? ""),
86
+ nowPlaying: String(r["now_playing"] ?? ""),
87
+ // BIGINT comes back as a string from pg, which sorts and compares
88
+ // nothing like a number.
89
+ startedAt: Number(r["started_at"] ?? 0),
90
+ endedAt: Number(r["ended_at"] ?? 0),
91
+ }));
92
+ }
93
+ catch (error) {
94
+ this.onEvent(` could not read ended streams: ${error.message}`);
95
+ return [];
96
+ }
97
+ }
98
+ async addReminder(code, phone) {
99
+ if (!code || !phone)
100
+ return;
101
+ await this.quietly("a reminder", () => this.db.query(`INSERT INTO stream_reminders (code, phone) VALUES ($1, $2)
102
+ ON CONFLICT DO NOTHING`, [code, phone]));
103
+ }
104
+ /**
105
+ * Take everyone waiting on a code, and stop them waiting, in one statement.
106
+ *
107
+ * `DELETE ... RETURNING` rather than a select and then a delete: the rows
108
+ * come back as they are removed, so two goings-live at once cannot both read
109
+ * the same list and text everybody twice.
110
+ */
111
+ async takeReminders(code) {
112
+ if (!code)
113
+ return [];
114
+ try {
115
+ await this.ensure();
116
+ const { rows } = await this.db.query("DELETE FROM stream_reminders WHERE code = $1 RETURNING phone", [code]);
117
+ return rows.map((r) => String(r["phone"] ?? "")).filter(Boolean);
118
+ }
119
+ catch (error) {
120
+ this.onEvent(` could not take reminders: ${error.message}`);
121
+ return [];
122
+ }
123
+ }
124
+ /** Everyone waiting, by code, to seed a process that has just started. */
125
+ async loadReminders() {
126
+ const waiting = new Map();
127
+ try {
128
+ await this.ensure();
129
+ const { rows } = await this.db.query("SELECT code, phone FROM stream_reminders", []);
130
+ for (const row of rows) {
131
+ const code = String(row["code"] ?? "");
132
+ const phone = String(row["phone"] ?? "");
133
+ if (!code || !phone)
134
+ continue;
135
+ const set = waiting.get(code) ?? new Set();
136
+ set.add(phone);
137
+ waiting.set(code, set);
138
+ }
139
+ }
140
+ catch (error) {
141
+ this.onEvent(` could not read reminders: ${error.message}`);
142
+ }
143
+ return waiting;
144
+ }
145
+ /** Forget what is too old to be worth telling anybody about. */
146
+ async sweep(endedBefore, remindersBefore) {
147
+ await this.quietly("sweeping", async () => {
148
+ await this.db.query("DELETE FROM ended_streams WHERE ended_at < $1", [endedBefore]);
149
+ // A reminder nobody has collected in a month is somebody who has long
150
+ // since stopped expecting a text.
151
+ await this.db.query("DELETE FROM stream_reminders WHERE created_at < $1", [
152
+ remindersBefore.toISOString(),
153
+ ]);
154
+ });
155
+ }
156
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Following a broadcaster, and where to reach the people who do.
3
+ *
4
+ * The phone line got here first, and its reminder is a different thing: you
5
+ * key a code, press 1, and are told once when that particular stream comes
6
+ * back. It is per-stream, one-shot, and tied to the handset you called from.
7
+ * That is right for somebody who dialled a number, and useless for somebody
8
+ * who wants to know whenever a person they like goes live, on whatever device
9
+ * they happen to be holding.
10
+ *
11
+ * So a follow is account to account, and delivery is a set of addresses rather
12
+ * than a phone number: an email, a phone if they gave one, and any number of
13
+ * browsers that have granted permission. One person with a laptop, a phone and
14
+ * a desktop app is three push subscriptions and one account.
15
+ *
16
+ * Unlike the rest of nixamp this is durable. The directory can afford to be a
17
+ * four-minute TTL because a stream that stops is a stream nobody is listening
18
+ * to; a follow has to outlive the stream by definition -- the whole point is
19
+ * to be told about a broadcast that is not happening yet.
20
+ *
21
+ * The query function is injected rather than a Pool being constructed here, so
22
+ * a test can describe a database instead of running one.
23
+ */
24
+ /** The slice of `pg` this needs. Postgres in production, a fake in tests. */
25
+ export interface Queryable {
26
+ query(text: string, values?: unknown[]): Promise<{
27
+ rows: Record<string, unknown>[];
28
+ }>;
29
+ }
30
+ /** A browser, desktop app or phone that has granted notification permission. */
31
+ export interface PushTarget {
32
+ endpoint: string;
33
+ p256dh: string;
34
+ auth: string;
35
+ }
36
+ /** Everywhere one follower can be reached. */
37
+ export interface Reachable {
38
+ accountId: string;
39
+ email: string;
40
+ /** E.164, if they gave one. Empty when they never did. */
41
+ phone: string;
42
+ /** Whether each channel is on. A follower who wants none is still a follower. */
43
+ wantsEmail: boolean;
44
+ wantsSms: boolean;
45
+ wantsWeb: boolean;
46
+ push: PushTarget[];
47
+ }
48
+ /** E.164, or nothing. A number we cannot dial is not a number worth storing. */
49
+ export declare function phoneFrom(value: unknown): string;
50
+ export declare class Follows {
51
+ private readonly db;
52
+ private ready;
53
+ constructor(db: Queryable);
54
+ /** Make the tables, once per process, on first use. */
55
+ private ensure;
56
+ follow(followerId: string, streamerId: string): Promise<boolean>;
57
+ unfollow(followerId: string, streamerId: string): Promise<void>;
58
+ /** Who this account follows. */
59
+ following(followerId: string): Promise<string[]>;
60
+ isFollowing(followerId: string, streamerId: string): Promise<boolean>;
61
+ followerCount(streamerId: string): Promise<number>;
62
+ /** Remember a browser that has granted permission. */
63
+ addPush(accountId: string, target: PushTarget): Promise<void>;
64
+ /**
65
+ * Forget a browser.
66
+ *
67
+ * Called when somebody turns notifications off, and again when a push is
68
+ * rejected as gone: a subscription outlives the browser that made it, and
69
+ * pushing to a dead endpoint forever is how a table becomes mostly rubbish.
70
+ */
71
+ removePush(endpoint: string): Promise<void>;
72
+ setPrefs(accountId: string, prefs: {
73
+ phone?: string;
74
+ wantsEmail?: boolean;
75
+ wantsSms?: boolean;
76
+ wantsWeb?: boolean;
77
+ }): Promise<void>;
78
+ prefs(accountId: string): Promise<{
79
+ phone: string;
80
+ wantsEmail: boolean;
81
+ wantsSms: boolean;
82
+ wantsWeb: boolean;
83
+ }>;
84
+ /**
85
+ * Everyone following this broadcaster, and every way to reach them.
86
+ *
87
+ * One query rather than one per follower. A broadcaster with a thousand
88
+ * followers going live should not be a thousand round trips while the
89
+ * publisher's heartbeat waits on the response.
90
+ */
91
+ audience(streamerId: string): Promise<Reachable[]>;
92
+ }