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.
- package/README.md +171 -0
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -0
- package/dist/broadcast.d.ts +96 -0
- package/dist/broadcast.js +193 -0
- package/dist/channels.d.ts +94 -0
- package/dist/channels.js +235 -0
- package/dist/connections.d.ts +6 -0
- package/dist/connections.js +13 -0
- package/dist/directory.d.ts +186 -0
- package/dist/directory.js +275 -0
- package/dist/durable.d.ts +70 -0
- package/dist/durable.js +156 -0
- package/dist/follows.d.ts +92 -0
- package/dist/follows.js +248 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +21 -0
- package/dist/manage.js +2 -1
- package/dist/notify.d.ts +83 -0
- package/dist/notify.js +126 -0
- package/dist/optin.d.ts +37 -0
- package/dist/optin.js +122 -0
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/partyline.d.ts +259 -0
- package/dist/partyline.js +616 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/playlist.js +5 -0
- package/dist/publish.d.ts +57 -0
- package/dist/publish.js +106 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +94 -0
- package/dist/server.js +1158 -12
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +26 -0
- package/dist/share.js +31 -0
- package/package.json +8 -2
- package/src/accounts.ts +193 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +13 -0
- package/src/directory.ts +362 -0
- package/src/durable.ts +215 -0
- package/src/follows.ts +307 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/notify.ts +217 -0
- package/src/optin.ts +128 -0
- package/src/owner.ts +113 -0
- package/src/partyline.ts +742 -0
- package/src/paywall.ts +198 -0
- package/src/playlist.ts +5 -0
- package/src/publish.ts +137 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +1304 -12
- package/src/session.ts +209 -0
- package/src/share.ts +40 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
- package/web/dist/assets/index-qRguFskX.js +1 -0
- package/web/dist/index.html +62 -6
- package/web/dist/install.sh +82 -0
- package/web/dist/sw.js +45 -3
- 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
|
+
}
|
package/dist/durable.js
ADDED
|
@@ -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
|
+
}
|