nixamp 0.3.0 → 0.4.1
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/directory.d.ts +126 -3
- package/dist/directory.js +171 -7
- 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/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/partyline.d.ts +259 -0
- package/dist/partyline.js +616 -0
- package/dist/paywall.js +1 -1
- package/dist/playlist.js +5 -0
- package/dist/publish.d.ts +21 -0
- package/dist/publish.js +18 -2
- package/dist/server.d.ts +15 -0
- package/dist/server.js +558 -11
- package/dist/share.d.ts +10 -0
- package/dist/share.js +12 -0
- package/package.json +5 -2
- package/src/directory.ts +232 -5
- package/src/durable.ts +215 -0
- package/src/follows.ts +307 -0
- package/src/notify.ts +217 -0
- package/src/optin.ts +128 -0
- package/src/partyline.ts +742 -0
- package/src/paywall.ts +1 -1
- package/src/playlist.ts +5 -0
- package/src/publish.ts +38 -2
- package/src/server.ts +610 -10
- package/src/share.ts +13 -0
- package/web/dist/assets/{index-0wAv50Ay.css → index-DSIDSSPF.css} +1 -1
- package/web/dist/assets/index-qRguFskX.js +1 -0
- package/web/dist/index.html +27 -2
- package/web/dist/install.sh +82 -0
- package/web/dist/sw.js +45 -3
- package/web/dist/assets/index-WYJ6R4uF.js +0 -1
package/dist/directory.d.ts
CHANGED
|
@@ -18,19 +18,76 @@ export declare const DEFAULT_DIRECTORY = "https://nixamp.com";
|
|
|
18
18
|
export interface Listing {
|
|
19
19
|
/** Assigned by the directory, so a publisher cannot claim someone else's. */
|
|
20
20
|
id: string;
|
|
21
|
+
/**
|
|
22
|
+
* A six-digit code for this stream, stable across the whole run.
|
|
23
|
+
*
|
|
24
|
+
* This is what somebody keys into the phone line. It has to be short enough
|
|
25
|
+
* to read out and survive being remembered, which the id is not.
|
|
26
|
+
*/
|
|
27
|
+
code: string;
|
|
21
28
|
name: string;
|
|
29
|
+
/**
|
|
30
|
+
* The account that announced it.
|
|
31
|
+
*
|
|
32
|
+
* Set from the signed-in publisher, never from the announcement body -- a
|
|
33
|
+
* stream that could name its own owner could name somebody else's, and
|
|
34
|
+
* followers would be told about a broadcast that person is not making.
|
|
35
|
+
*/
|
|
36
|
+
ownerId: string;
|
|
22
37
|
/** The listen link, which is what a browser opens. */
|
|
23
38
|
url: string;
|
|
39
|
+
/**
|
|
40
|
+
* The same stream as bytes, for something that is not a browser.
|
|
41
|
+
*
|
|
42
|
+
* `url` is a share link: it answers 302, sets a cookie and redirects to the
|
|
43
|
+
* player page. That is exactly right for a person and useless to anything
|
|
44
|
+
* that cannot hold a cookie -- the phone line hands this address to Telnyx
|
|
45
|
+
* to play into a call, and Telnyx fetches it once, anonymously, and expects
|
|
46
|
+
* audio back. Handed the share link it gets a 401 in JSON and the caller
|
|
47
|
+
* hears silence after being told the stream is about to start.
|
|
48
|
+
*
|
|
49
|
+
* So a publisher announces both: the link a person opens, and the address
|
|
50
|
+
* that answers with audio/mpeg to a plain GET. Empty when the publisher is
|
|
51
|
+
* an older nixamp that only knows about `url`.
|
|
52
|
+
*/
|
|
53
|
+
audio: string;
|
|
24
54
|
tracks: number;
|
|
25
55
|
nowPlaying: string;
|
|
26
56
|
/** Set by the directory from the request, never by the publisher. */
|
|
27
57
|
updatedAt: number;
|
|
58
|
+
/** When this stream first announced itself: the "started at" a caller hears. */
|
|
59
|
+
startedAt: number;
|
|
28
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* A stream that has stopped, kept for a while after it fell out of the list.
|
|
63
|
+
*
|
|
64
|
+
* The directory proper forgets a stream the moment it stops renewing, which is
|
|
65
|
+
* right for a list of what is on -- but it means there is nobody left to say
|
|
66
|
+
* *when* it ended, and "call back later" with no time in it is not worth
|
|
67
|
+
* saying. So an ended stream leaves this behind: enough to answer the phone
|
|
68
|
+
* truthfully, and nothing anybody could listen to.
|
|
69
|
+
*/
|
|
70
|
+
export interface Ended {
|
|
71
|
+
id: string;
|
|
72
|
+
code: string;
|
|
73
|
+
name: string;
|
|
74
|
+
ownerId: string;
|
|
75
|
+
/** Kept so a stream returning on the same url is recognised as the same one. */
|
|
76
|
+
url: string;
|
|
77
|
+
nowPlaying: string;
|
|
78
|
+
startedAt: number;
|
|
79
|
+
/** The last heartbeat we saw, which is as close to "ended" as we can know. */
|
|
80
|
+
endedAt: number;
|
|
81
|
+
}
|
|
82
|
+
/** How long an ended stream is still worth telling a caller about. */
|
|
83
|
+
export declare const ENDED_TTL_MS: number;
|
|
29
84
|
/** What a publisher sends. Everything else about a listing is ours to decide. */
|
|
30
85
|
export interface Announcement {
|
|
31
86
|
id?: string;
|
|
32
87
|
name: string;
|
|
33
88
|
url: string;
|
|
89
|
+
/** Where the audio actually is. See `Listing.audio`. */
|
|
90
|
+
audio?: string;
|
|
34
91
|
tracks: number;
|
|
35
92
|
nowPlaying: string;
|
|
36
93
|
}
|
|
@@ -52,12 +109,78 @@ export declare function parseAnnouncement(input: unknown): Announcement | null;
|
|
|
52
109
|
export declare class Directory {
|
|
53
110
|
private readonly ttl;
|
|
54
111
|
private readonly now;
|
|
112
|
+
/** Injected so a test can make a code predictable rather than guess it. */
|
|
113
|
+
private readonly randomCode;
|
|
114
|
+
/**
|
|
115
|
+
* Called when a stream starts, and only then.
|
|
116
|
+
*
|
|
117
|
+
* A publisher announces every ninety seconds for as long as it is up, so
|
|
118
|
+
* "announced" is not "went live" -- telling followers on every heartbeat
|
|
119
|
+
* would be telling them forty times an hour. This fires on the transition
|
|
120
|
+
* and not on the renewals that follow it.
|
|
121
|
+
*/
|
|
122
|
+
private readonly onLive;
|
|
55
123
|
private readonly items;
|
|
124
|
+
/** Streams that stopped, so the phone line can say when. */
|
|
125
|
+
private readonly ended;
|
|
56
126
|
private sequence;
|
|
57
|
-
|
|
58
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Somewhere to echo the ended list, so it survives a restart.
|
|
129
|
+
*
|
|
130
|
+
* Attached after construction rather than taken as a constructor argument:
|
|
131
|
+
* this is a mirror, not a dependency, and the directory works exactly as it
|
|
132
|
+
* did without one.
|
|
133
|
+
*/
|
|
134
|
+
private mirror;
|
|
135
|
+
/** Start echoing ended streams somewhere durable. */
|
|
136
|
+
persistTo(mirror: {
|
|
137
|
+
save: (item: Ended) => void;
|
|
138
|
+
drop: (id: string) => void;
|
|
139
|
+
}): void;
|
|
140
|
+
/**
|
|
141
|
+
* Put back what a previous process knew.
|
|
142
|
+
*
|
|
143
|
+
* Only fills gaps: anything already here was announced since we started and
|
|
144
|
+
* is newer than a row written before the restart.
|
|
145
|
+
*/
|
|
146
|
+
seedEnded(items: readonly Ended[]): void;
|
|
147
|
+
constructor(ttl?: number, now?: () => number,
|
|
148
|
+
/** Injected so a test can make a code predictable rather than guess it. */
|
|
149
|
+
randomCode?: () => string,
|
|
150
|
+
/**
|
|
151
|
+
* Called when a stream starts, and only then.
|
|
152
|
+
*
|
|
153
|
+
* A publisher announces every ninety seconds for as long as it is up, so
|
|
154
|
+
* "announced" is not "went live" -- telling followers on every heartbeat
|
|
155
|
+
* would be telling them forty times an hour. This fires on the transition
|
|
156
|
+
* and not on the renewals that follow it.
|
|
157
|
+
*/
|
|
158
|
+
onLive?: (listing: Listing) => void);
|
|
159
|
+
announce(announcement: Announcement, ownerId?: string): Listing;
|
|
59
160
|
withdraw(id: string): void;
|
|
60
161
|
list(): Listing[];
|
|
61
|
-
/**
|
|
162
|
+
/** The live stream on this code, if there is one. */
|
|
163
|
+
liveByCode(code: string): Listing | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* Streams that stopped recently, most recent first.
|
|
166
|
+
*
|
|
167
|
+
* Kept for the phone line, which has to say when a stream ended -- but they
|
|
168
|
+
* answer a second question the live list cannot: who is there to follow.
|
|
169
|
+
* Following exists to hear about broadcasts you would otherwise miss, and a
|
|
170
|
+
* directory that only lists what is on can only be used to follow somebody
|
|
171
|
+
* during a broadcast you did not miss.
|
|
172
|
+
*/
|
|
173
|
+
recentlyEnded(): Ended[];
|
|
174
|
+
/** The name last used by an account, live or recently ended. */
|
|
175
|
+
nameOf(ownerId: string): string;
|
|
176
|
+
/** Whether this account is streaming right now. */
|
|
177
|
+
isLive(ownerId: string): boolean;
|
|
178
|
+
/** The stream that used to be on this code, if it stopped recently. */
|
|
179
|
+
endedByCode(code: string): Ended | undefined;
|
|
180
|
+
private endedByUrl;
|
|
181
|
+
private remember;
|
|
182
|
+
/** A code no live and no recently-ended stream is using. */
|
|
183
|
+
private freeCode;
|
|
184
|
+
/** Forget anything that stopped renewing, keeping a note of when it did. */
|
|
62
185
|
private sweep;
|
|
63
186
|
}
|
package/dist/directory.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
1
2
|
/**
|
|
2
3
|
* The public directory.
|
|
3
4
|
*
|
|
@@ -15,6 +16,8 @@ export const TTL_MS = 4 * 60 * 1000;
|
|
|
15
16
|
/** How often a publisher renews. Comfortably inside the TTL. */
|
|
16
17
|
export const HEARTBEAT_MS = 90 * 1000;
|
|
17
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;
|
|
18
21
|
const MAX_NAME = 60;
|
|
19
22
|
const MAX_TRACK = 120;
|
|
20
23
|
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
@@ -53,14 +56,23 @@ export function parseAnnouncement(input) {
|
|
|
53
56
|
return null;
|
|
54
57
|
const record = input;
|
|
55
58
|
const url = typeof record["url"] === "string" ? record["url"] : "";
|
|
56
|
-
|
|
59
|
+
const listen = publishable(url);
|
|
60
|
+
if (listen === null)
|
|
57
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 : "";
|
|
58
69
|
const name = clean(record["name"], MAX_NAME);
|
|
59
70
|
const tracks = Number(record["tracks"]);
|
|
60
71
|
return {
|
|
61
72
|
...(typeof record["id"] === "string" ? { id: clean(record["id"], 40) } : {}),
|
|
62
73
|
name: name || "a nixamp",
|
|
63
74
|
url,
|
|
75
|
+
...(audio ? { audio } : {}),
|
|
64
76
|
tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
|
|
65
77
|
nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
|
|
66
78
|
};
|
|
@@ -73,39 +85,191 @@ export function parseAnnouncement(input) {
|
|
|
73
85
|
export class Directory {
|
|
74
86
|
ttl;
|
|
75
87
|
now;
|
|
88
|
+
randomCode;
|
|
89
|
+
onLive;
|
|
76
90
|
items = new Map();
|
|
91
|
+
/** Streams that stopped, so the phone line can say when. */
|
|
92
|
+
ended = new Map();
|
|
77
93
|
sequence = 0;
|
|
78
|
-
|
|
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 = () => { }) {
|
|
79
130
|
this.ttl = ttl;
|
|
80
131
|
this.now = now;
|
|
132
|
+
this.randomCode = randomCode;
|
|
133
|
+
this.onLive = onLive;
|
|
81
134
|
}
|
|
82
|
-
announce(announcement) {
|
|
135
|
+
announce(announcement, ownerId = "") {
|
|
83
136
|
this.sweep();
|
|
84
137
|
const existing = [...this.items.values()].find((item) => item.url === announcement.url);
|
|
85
|
-
|
|
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
|
+
}
|
|
86
147
|
const listing = {
|
|
87
148
|
id,
|
|
149
|
+
code,
|
|
88
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 || "",
|
|
89
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 ?? "",
|
|
90
159
|
tracks: announcement.tracks,
|
|
91
160
|
nowPlaying: announcement.nowPlaying,
|
|
92
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(),
|
|
93
165
|
};
|
|
94
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);
|
|
95
170
|
return listing;
|
|
96
171
|
}
|
|
97
172
|
withdraw(id) {
|
|
173
|
+
const item = this.items.get(id);
|
|
174
|
+
if (item !== undefined)
|
|
175
|
+
this.remember(item);
|
|
98
176
|
this.items.delete(id);
|
|
99
177
|
}
|
|
100
178
|
list() {
|
|
101
179
|
this.sweep();
|
|
102
180
|
return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
103
181
|
}
|
|
104
|
-
/**
|
|
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. */
|
|
105
259
|
sweep() {
|
|
106
260
|
const cutoff = this.now() - this.ttl;
|
|
107
|
-
for (const [id, item] of this.items)
|
|
108
|
-
if (item.updatedAt < cutoff)
|
|
261
|
+
for (const [id, item] of this.items) {
|
|
262
|
+
if (item.updatedAt < cutoff) {
|
|
263
|
+
this.remember(item);
|
|
109
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
|
+
}
|
|
110
274
|
}
|
|
111
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
|
+
}
|