@protobase/server 0.59.3 → 0.60.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/dist/calls/admission.d.ts +4 -0
- package/dist/calls/admission.js +12 -0
- package/dist/calls/call-hub.js +170 -0
- package/dist/calls/call-member.d.ts +19 -6
- package/dist/calls/call-reads.d.ts +29 -0
- package/dist/calls/call-reads.js +79 -0
- package/dist/calls/call-signals-sql.d.ts +12 -0
- package/dist/calls/call-signals-sql.js +19 -0
- package/dist/calls/call-store.d.ts +30 -0
- package/dist/calls/call-store.js +112 -0
- package/dist/calls/call-sweep.js +26 -0
- package/dist/calls/call-watch.js +39 -0
- package/dist/calls/calls-access.d.ts +20 -5
- package/dist/calls/calls-access.js +38 -17
- package/dist/calls/calls-options.d.ts +9 -1
- package/dist/calls/calls-options.js +10 -2
- package/dist/calls/calls-runtime.d.ts +33 -0
- package/dist/calls/calls-runtime.js +94 -0
- package/dist/calls/calls-service-type.d.ts +11 -0
- package/dist/calls/calls-service.js +27 -0
- package/dist/calls/calls-sql.d.ts +7 -0
- package/dist/calls/calls-sql.js +66 -0
- package/dist/calls/calls-store.d.ts +23 -0
- package/dist/calls/calls-store.js +20 -0
- package/dist/calls/client-message.js +1 -0
- package/dist/calls/close-codes.js +17 -0
- package/dist/calls/livekit/livekit-session.js +1 -0
- package/dist/calls/member-relay.js +69 -17
- package/dist/calls/peer/peer-backend.js +68 -120
- package/dist/calls/record-calls.js +78 -0
- package/dist/create-admin.js +4 -2
- package/dist/deps.d.ts +3 -0
- package/dist/functions/function-socket.d.ts +1 -0
- package/dist/functions/function-socket.js +14 -2
- package/dist/functions/routes.js +3 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/package.js +1 -1
- package/dist/routes/record.js +6 -0
- package/dist/webhooks/attempt.js +6 -1
- package/package.json +4 -4
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/calls/admission.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether a member may come into a call they did not resume from an ended one: never once removed; a member already
|
|
4
|
+
* in it takes their place over, as from another tab; anyone else while the call has room.
|
|
5
|
+
*/
|
|
6
|
+
const admission = ({ removed, present, member, maxMembers }) => {
|
|
7
|
+
if (removed) return "removed";
|
|
8
|
+
if (present.includes(member)) return;
|
|
9
|
+
if (present.length >= maxMembers) return "full";
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { admission };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { fixedWindow } from "../realtime/fixed-window.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
//#region src/calls/call-hub.ts
|
|
4
|
+
/** Signals a member may pass in a second at most; more are dropped. */
|
|
5
|
+
const signalsPerSecond = 100;
|
|
6
|
+
/** How often the signals for this process are read, with several processes. */
|
|
7
|
+
const signalsEveryMs = 100;
|
|
8
|
+
const rosterOf = (members) => members.map(({ member, joined_at }) => ({
|
|
9
|
+
member,
|
|
10
|
+
joined_at
|
|
11
|
+
}));
|
|
12
|
+
/**
|
|
13
|
+
* The members this process serves, in their calls, whose records the `calls` schema keeps. A member is in only while
|
|
14
|
+
* the call's record says so, with this process serving them: a removal, the call's end or another process taking
|
|
15
|
+
* them over closes their socket at the next read, a second at most. A signal goes from a member in a call to another
|
|
16
|
+
* member of the same call only, straight to their socket here, or through the `calls` schema to the process that
|
|
17
|
+
* serves them.
|
|
18
|
+
*/
|
|
19
|
+
const callHub = ({ store, manyProcesses, report }) => {
|
|
20
|
+
const process = randomUUID();
|
|
21
|
+
const locals = /* @__PURE__ */ new Map();
|
|
22
|
+
const known = /* @__PURE__ */ new Map();
|
|
23
|
+
let signalTimer;
|
|
24
|
+
const ofCall = (call) => [...locals.values()].filter((local) => local.call === call);
|
|
25
|
+
const drop = (local, reason) => {
|
|
26
|
+
locals.delete(local.row);
|
|
27
|
+
local.link.send({
|
|
28
|
+
type: "left",
|
|
29
|
+
reason
|
|
30
|
+
});
|
|
31
|
+
local.link.close(reason === "ended" ? 1e3 : 1008, reason);
|
|
32
|
+
};
|
|
33
|
+
const apply = (call, found) => {
|
|
34
|
+
const members = found && !found.ended ? found.members : [];
|
|
35
|
+
if (members.length === 0) known.delete(call);
|
|
36
|
+
else known.set(call, members);
|
|
37
|
+
for (const local of ofCall(call)) {
|
|
38
|
+
if (!members.some((member) => member.row === local.row && member.process === process)) {
|
|
39
|
+
drop(local, !found || found.ended ? "ended" : "removed");
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!local.listener) continue;
|
|
43
|
+
const body = JSON.stringify(members);
|
|
44
|
+
if (local.sent === body) continue;
|
|
45
|
+
local.sent = body;
|
|
46
|
+
local.link.send({
|
|
47
|
+
type: "members",
|
|
48
|
+
members: rosterOf(members)
|
|
49
|
+
});
|
|
50
|
+
local.listener?.(members);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/** Reads who is in these calls and tells their members here. */
|
|
54
|
+
const refresh = async (calls) => {
|
|
55
|
+
const found = await store.rosters(calls);
|
|
56
|
+
for (const call of calls) apply(call, found.get(call));
|
|
57
|
+
};
|
|
58
|
+
const deliver = (call, to, from, body) => {
|
|
59
|
+
ofCall(call).find((local) => local.member === to)?.link.send({
|
|
60
|
+
type: "signal",
|
|
61
|
+
from,
|
|
62
|
+
...body.description && { description: body.description },
|
|
63
|
+
...body.candidate && { candidate: body.candidate }
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
const inCall = async (call, member) => {
|
|
67
|
+
const find = () => known.get(call)?.find((present) => present.member === member);
|
|
68
|
+
if (find()) return find();
|
|
69
|
+
await refresh([call]);
|
|
70
|
+
return find();
|
|
71
|
+
};
|
|
72
|
+
const readSignals = async () => {
|
|
73
|
+
for (const signal of await store.takeSignals(process, 500)) {
|
|
74
|
+
if (!await inCall(signal.call, signal.from)) continue;
|
|
75
|
+
deliver(signal.call, signal.to, signal.from, signal.body);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const followSignals = () => {
|
|
79
|
+
if (!manyProcesses || signalTimer || locals.size === 0) return;
|
|
80
|
+
const tick = () => {
|
|
81
|
+
if (locals.size === 0) {
|
|
82
|
+
signalTimer = void 0;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
readSignals().catch(report).finally(() => {
|
|
86
|
+
signalTimer = setTimeout(tick, signalsEveryMs);
|
|
87
|
+
signalTimer.unref?.();
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
signalTimer = setTimeout(tick, signalsEveryMs);
|
|
91
|
+
signalTimer.unref?.();
|
|
92
|
+
};
|
|
93
|
+
const signal = async (self, to, body) => {
|
|
94
|
+
if (locals.get(self.row) !== self || !self.signals()) return;
|
|
95
|
+
if (!await inCall(self.call, self.member)) return;
|
|
96
|
+
const target = await inCall(self.call, to);
|
|
97
|
+
if (!target || target.member === self.member) return;
|
|
98
|
+
if (target.process === process) return deliver(self.call, to, self.member, body);
|
|
99
|
+
if (manyProcesses) await store.writeSignal({
|
|
100
|
+
process: target.process,
|
|
101
|
+
call: self.call,
|
|
102
|
+
to,
|
|
103
|
+
from: self.member,
|
|
104
|
+
body
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
process,
|
|
109
|
+
/** How many members this process serves. */
|
|
110
|
+
get size() {
|
|
111
|
+
return locals.size;
|
|
112
|
+
},
|
|
113
|
+
/**
|
|
114
|
+
* Lets the member in, or says why not. Once in, `listen` gets who is in the call; `leave` ends their time in it,
|
|
115
|
+
* `detach` only stops this process hearing of them, as when their socket was lost: they are out `graceSeconds`
|
|
116
|
+
* later unless their browser resumes.
|
|
117
|
+
*/
|
|
118
|
+
admit: async (input, link) => {
|
|
119
|
+
const admitted = await store.admit({
|
|
120
|
+
...input,
|
|
121
|
+
process
|
|
122
|
+
});
|
|
123
|
+
if ("refused" in admitted) return admitted;
|
|
124
|
+
const local = {
|
|
125
|
+
call: admitted.call,
|
|
126
|
+
member: input.member,
|
|
127
|
+
row: admitted.row,
|
|
128
|
+
link,
|
|
129
|
+
signals: fixedWindow(signalsPerSecond, 1e3)
|
|
130
|
+
};
|
|
131
|
+
for (const before of ofCall(admitted.call)) if (before.member === input.member && before.row === admitted.row) drop(before, "removed");
|
|
132
|
+
locals.set(local.row, local);
|
|
133
|
+
followSignals();
|
|
134
|
+
const room = {
|
|
135
|
+
call: admitted.call,
|
|
136
|
+
tracks: async (tracks) => {
|
|
137
|
+
if (locals.get(local.row) !== local) return;
|
|
138
|
+
await store.setTracks(local.row, process, tracks);
|
|
139
|
+
await refresh([local.call]);
|
|
140
|
+
},
|
|
141
|
+
signal: (to, body) => signal(local, to, body)
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
call: admitted.call,
|
|
145
|
+
room,
|
|
146
|
+
listen: async (listener) => {
|
|
147
|
+
local.listener = listener;
|
|
148
|
+
await refresh([local.call]);
|
|
149
|
+
},
|
|
150
|
+
leave: async () => {
|
|
151
|
+
if (locals.get(local.row) !== local) return;
|
|
152
|
+
locals.delete(local.row);
|
|
153
|
+
await store.leave(local.row, process);
|
|
154
|
+
await refresh([local.call]);
|
|
155
|
+
},
|
|
156
|
+
detach: () => {
|
|
157
|
+
if (locals.get(local.row) === local) locals.delete(local.row);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
/** Reads every call this process serves a member of, and tells them; a member no longer in theirs is closed out. */
|
|
162
|
+
refreshAll: () => refresh([...new Set([...locals.values()].map((local) => local.call))]),
|
|
163
|
+
/** Tells the calls' records that this process still serves its members. */
|
|
164
|
+
beat: () => store.heartbeat(process, [...locals.keys()]),
|
|
165
|
+
/** The calls this process serves a member of, for a change made here, such as a removal. */
|
|
166
|
+
refresh
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
//#endregion
|
|
170
|
+
export { callHub };
|
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import { CallRemoteTrack, CallTrackSource } from "@protobase/schema/internal";
|
|
2
2
|
//#region src/calls/call-member.d.ts
|
|
3
|
-
/**
|
|
4
|
-
export type
|
|
5
|
-
/** The call, as the app names it: members with the same call meet. */
|
|
6
|
-
call: string;
|
|
7
|
-
/** Who joins, unique in the call, such as the user's id: the media server knows them by nothing else. */
|
|
8
|
-
member: string;
|
|
3
|
+
/** What a member may send and hear in a call. */
|
|
4
|
+
export type CallRights = {
|
|
9
5
|
/** Whether the member may send media, or only these sources; nothing when `false`. */
|
|
10
6
|
publish: boolean | CallTrackSource[];
|
|
11
7
|
/** Which of the other members' tracks the member hears; every one by default. */
|
|
12
8
|
hears?: (track: CallRemoteTrack) => boolean;
|
|
13
9
|
};
|
|
10
|
+
/** A record's key, as the records of a function take it: one value, or the values of a key of several fields. */
|
|
11
|
+
export type CallRecordKey = string | number | Array<string | number>;
|
|
12
|
+
/** One member admitted to the call on a record, with what they may send and hear. */
|
|
13
|
+
export type CallMember = CallRights & {
|
|
14
|
+
/** The record the call is on: whoever reads it sees its calls. The caller must be able to read it to join. */
|
|
15
|
+
record: {
|
|
16
|
+
resource: string;
|
|
17
|
+
key: CallRecordKey;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Who joins, unique in the call, such as the signed-in user's id: take it from the caller, never from the request,
|
|
21
|
+
* since the other members know them by it.
|
|
22
|
+
*/
|
|
23
|
+
member: string;
|
|
24
|
+
/** The function's own checks, repeated every minute after the record's, such as whether the caller is still invited. */
|
|
25
|
+
recheck?: () => unknown;
|
|
26
|
+
};
|
|
14
27
|
//#endregion
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import "./call-store.js";
|
|
2
|
+
import { CallRemoteTrack } from "@protobase/schema/internal";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/calls/call-reads.d.ts
|
|
5
|
+
/** Someone in a call now: their members row, the process that serves them and the tracks they send. */
|
|
6
|
+
export type RosterMember = {
|
|
7
|
+
row: string;
|
|
8
|
+
member: string;
|
|
9
|
+
process: string;
|
|
10
|
+
joined_at: string;
|
|
11
|
+
tracks: CallRemoteTrack[];
|
|
12
|
+
};
|
|
13
|
+
/** A call as the record's readers see it: who started it, when, when it ended, who is in it and who was removed. */
|
|
14
|
+
export type CallItem = {
|
|
15
|
+
id: string;
|
|
16
|
+
started_by: string;
|
|
17
|
+
started_at: string;
|
|
18
|
+
/** `null` while it goes on. */
|
|
19
|
+
ended_at: string | null;
|
|
20
|
+
members: {
|
|
21
|
+
member: string;
|
|
22
|
+
joined_at: string;
|
|
23
|
+
}[];
|
|
24
|
+
removed: {
|
|
25
|
+
member: string;
|
|
26
|
+
removed_at: string;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/calls/call-reads.ts
|
|
3
|
+
const iso = (column) => sql.raw(`to_char(${column} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`);
|
|
4
|
+
const ids = (values) => `{${values.join(",")}}`;
|
|
5
|
+
/** Who is in each of these calls now, in the order they came; a call that ended has nobody. Unknown calls are left out. */
|
|
6
|
+
const rosters = async (db, calls) => {
|
|
7
|
+
const found = /* @__PURE__ */ new Map();
|
|
8
|
+
if (calls.length === 0) return found;
|
|
9
|
+
const { rows } = await sql`
|
|
10
|
+
select c."id"::text as call, c."ended_at" is not null as ended, m."id"::text as row, m."member", m."process"::text as process, ${iso("m.\"joined_at\"")} as joined_at, m."tracks"
|
|
11
|
+
from "calls"."calls" c left join "calls"."members" m on m."call_id" = c."id" and m."left_at" is null
|
|
12
|
+
where c."id" = any(${ids(calls)}::uuid[]) order by c."id", m."joined_at", m."id"`.execute(db);
|
|
13
|
+
for (const row of rows) {
|
|
14
|
+
const call = found.get(row.call) ?? {
|
|
15
|
+
ended: row.ended,
|
|
16
|
+
members: []
|
|
17
|
+
};
|
|
18
|
+
found.set(row.call, call);
|
|
19
|
+
if (row.row !== null) call.members.push({
|
|
20
|
+
row: row.row,
|
|
21
|
+
member: row.member,
|
|
22
|
+
process: row.process,
|
|
23
|
+
joined_at: row.joined_at,
|
|
24
|
+
tracks: row.tracks
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return found;
|
|
28
|
+
};
|
|
29
|
+
/** Each record's latest calls: the one going on first, then the newest first; keyed `resource` NUL `key`. */
|
|
30
|
+
const recordCalls = async (db, records) => {
|
|
31
|
+
const found = new Map(records.map((record) => [recordId(record), []]));
|
|
32
|
+
if (records.length === 0) return found;
|
|
33
|
+
const values = JSON.stringify(records.map((record) => ({
|
|
34
|
+
resource: record.resource,
|
|
35
|
+
record_key: record.key
|
|
36
|
+
})));
|
|
37
|
+
const calls = await sql`
|
|
38
|
+
select c."id"::text as id, c."resource", c."record_key", c."started_by", ${iso("c.\"started_at\"")} as started_at, ${iso("c.\"ended_at\"")} as ended_at
|
|
39
|
+
from jsonb_to_recordset(${values}::jsonb) as r("resource" text, "record_key" text)
|
|
40
|
+
cross join lateral (select * from "calls"."calls" k where k."resource" = r."resource" and k."record_key" = r."record_key" order by k."ended_at" is not null, k."started_at" desc, k."id" limit ${20}) c
|
|
41
|
+
order by c."ended_at" is not null, c."started_at" desc, c."id"`.execute(db);
|
|
42
|
+
if (calls.rows.length === 0) return found;
|
|
43
|
+
const members = await sql`
|
|
44
|
+
select "call_id"::text as call, "member", ${iso("\"joined_at\"")} as joined_at, ${iso("\"removed_at\"")} as removed_at
|
|
45
|
+
from "calls"."members" where "call_id" = any(${ids(calls.rows.map((call) => call.id))}::uuid[]) and ("left_at" is null or "removed_at" is not null)
|
|
46
|
+
order by "joined_at", "id"`.execute(db);
|
|
47
|
+
const items = /* @__PURE__ */ new Map();
|
|
48
|
+
for (const call of calls.rows) {
|
|
49
|
+
const item = {
|
|
50
|
+
id: call.id,
|
|
51
|
+
started_by: call.started_by,
|
|
52
|
+
started_at: call.started_at,
|
|
53
|
+
ended_at: call.ended_at,
|
|
54
|
+
members: [],
|
|
55
|
+
removed: []
|
|
56
|
+
};
|
|
57
|
+
items.set(call.id, item);
|
|
58
|
+
found.get(recordId({
|
|
59
|
+
resource: call.resource,
|
|
60
|
+
key: call.record_key
|
|
61
|
+
})).push(item);
|
|
62
|
+
}
|
|
63
|
+
for (const row of members.rows) {
|
|
64
|
+
const item = items.get(row.call);
|
|
65
|
+
if (row.removed_at === null) item.members.push({
|
|
66
|
+
member: row.member,
|
|
67
|
+
joined_at: row.joined_at
|
|
68
|
+
});
|
|
69
|
+
else item.removed.push({
|
|
70
|
+
member: row.member,
|
|
71
|
+
removed_at: row.removed_at
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return found;
|
|
75
|
+
};
|
|
76
|
+
/** One string for a record, for maps. */
|
|
77
|
+
const recordId = ({ resource, key }) => `${resource}\u0000${key}`;
|
|
78
|
+
//#endregion
|
|
79
|
+
export { recordCalls, recordId, rosters };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Db } from "@protobase/query";
|
|
2
|
+
//#region src/calls/call-signals-sql.d.ts
|
|
3
|
+
/** A member's description or candidate for a member another process serves, addressed to that process. */
|
|
4
|
+
export type StoredSignal = {
|
|
5
|
+
id: string;
|
|
6
|
+
process: string;
|
|
7
|
+
call: string;
|
|
8
|
+
to: string;
|
|
9
|
+
from: string;
|
|
10
|
+
body: unknown;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/calls/call-signals-sql.ts
|
|
3
|
+
const writeSignal = async (db, signal) => {
|
|
4
|
+
await sql`insert into "calls"."signals" ("process", "call_id", "to_member", "from_member", "body", "at")
|
|
5
|
+
values (${signal.process}::uuid, ${signal.call}::uuid, ${signal.to}, ${signal.from}, ${JSON.stringify(signal.body)}::json, now())`.execute(db);
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Takes the signals for `process` out of the table, at most `limit`, in the order they were written: each process
|
|
9
|
+
* reads only its own, so ids that commit out of order across processes are never skipped. A sender's signals are
|
|
10
|
+
* written one after the other, so they come in their order.
|
|
11
|
+
*/
|
|
12
|
+
const takeSignals = async (db, process, limit) => {
|
|
13
|
+
const { rows } = await sql`delete from "calls"."signals" where "id" in (
|
|
14
|
+
select "id" from "calls"."signals" where "process" = ${process}::uuid order by "id" limit ${limit} for update skip locked)
|
|
15
|
+
returning "id"::text as id, "process"::text as process, "call_id"::text as call, "to_member" as to, "from_member" as from, "body"`.execute(db);
|
|
16
|
+
return rows.sort((a, b) => BigInt(a.id) < BigInt(b.id) ? -1 : 1);
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { takeSignals, writeSignal };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Refusal } from "./admission.js";
|
|
2
|
+
import { CallRemoteTrack } from "@protobase/schema/internal";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/calls/call-store.d.ts
|
|
5
|
+
/** A record a call is on: its resource and its encoded key. */
|
|
6
|
+
export type CallRecord = {
|
|
7
|
+
resource: string;
|
|
8
|
+
key: string;
|
|
9
|
+
};
|
|
10
|
+
export type AdmitInput = {
|
|
11
|
+
record: CallRecord;
|
|
12
|
+
member: string;
|
|
13
|
+
/** The process that serves the member. */
|
|
14
|
+
process: string;
|
|
15
|
+
/** The call the browser resumes, with the tracks it sends; a fresh join without. */
|
|
16
|
+
resume?: {
|
|
17
|
+
call: string;
|
|
18
|
+
tracks: CallRemoteTrack[];
|
|
19
|
+
};
|
|
20
|
+
maxMembers: number;
|
|
21
|
+
maxMinutes: number;
|
|
22
|
+
};
|
|
23
|
+
/** The member in the call `call`, as the members row `row`; or why not. */
|
|
24
|
+
export type Admission = {
|
|
25
|
+
call: string;
|
|
26
|
+
row: string;
|
|
27
|
+
} | {
|
|
28
|
+
refused: Refusal;
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { writeTransaction } from "../transactions.js";
|
|
2
|
+
import { admission } from "./admission.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
/** Whether `id` can be a call's id, a UUID: anything else is no call. */
|
|
5
|
+
const isCallId = (id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
6
|
+
/** The process of a removal row, of someone removed while not in the call. */
|
|
7
|
+
const nobody = "00000000-0000-0000-0000-000000000000";
|
|
8
|
+
const lockCall = async (trx, id) => (await sql`select "id"::text as id, "resource", "record_key", "ended_at" is not null as ended, "ends_at" <= now() as expired
|
|
9
|
+
from "calls"."calls" where "id" = ${id}::uuid for update`.execute(trx)).rows[0];
|
|
10
|
+
/** Ends the call and the time of everyone still in it; the call's row is locked. */
|
|
11
|
+
const endCall = async (trx, id) => {
|
|
12
|
+
await sql`update "calls"."calls" set "ended_at" = now() where "id" = ${id}::uuid and "ended_at" is null`.execute(trx);
|
|
13
|
+
await sql`update "calls"."members" set "left_at" = now() where "call_id" = ${id}::uuid and "left_at" is null`.execute(trx);
|
|
14
|
+
};
|
|
15
|
+
const endIfEmpty = async (trx, id) => {
|
|
16
|
+
const { rows } = await sql`select "id" from "calls"."members" where "call_id" = ${id}::uuid and "left_at" is null limit 1`.execute(trx);
|
|
17
|
+
if (rows.length === 0) await endCall(trx, id);
|
|
18
|
+
};
|
|
19
|
+
const liveCall = async (trx, record, member, maxMinutes) => {
|
|
20
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
21
|
+
await sql`insert into "calls"."calls" ("id", "resource", "record_key", "started_by", "started_at", "ends_at")
|
|
22
|
+
values (gen_random_uuid(), ${record.resource}, ${record.key}, ${member}, now(), now() + make_interval(mins => ${maxMinutes}))
|
|
23
|
+
on conflict ("resource", "record_key") where "ended_at" is null do nothing`.execute(trx);
|
|
24
|
+
const { rows } = await sql`select "id"::text as id from "calls"."calls"
|
|
25
|
+
where "resource" = ${record.resource} and "record_key" = ${record.key} and "ended_at" is null`.execute(trx);
|
|
26
|
+
const call = rows[0] && await lockCall(trx, rows[0].id);
|
|
27
|
+
if (call && !call.ended && !call.expired) return call.id;
|
|
28
|
+
if (call && !call.ended) await endCall(trx, call.id);
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`The call on ${record.resource} ${record.key} could not be started`);
|
|
31
|
+
};
|
|
32
|
+
const enter = async (trx, call, member, process, tracks) => {
|
|
33
|
+
const body = JSON.stringify(tracks);
|
|
34
|
+
const { rows } = await sql`update "calls"."members" set "process" = ${process}::uuid, "seen_at" = now(), "tracks" = ${body}::jsonb
|
|
35
|
+
where "call_id" = ${call}::uuid and "member" = ${member} and "left_at" is null returning "id"::text as id`.execute(trx);
|
|
36
|
+
if (rows[0]) return rows[0].id;
|
|
37
|
+
return (await sql`insert into "calls"."members" ("call_id", "member", "process", "tracks", "joined_at", "seen_at")
|
|
38
|
+
values (${call}::uuid, ${member}, ${process}::uuid, ${body}::jsonb, now(), now()) returning "id"::text as id`.execute(trx)).rows[0].id;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Lets the member into the record's call, or the call they resume, when they may: one transaction, the call's row
|
|
42
|
+
* locked. The members whose process stopped hearing of them are out first. A resume of a call that ended, or that is
|
|
43
|
+
* on another record, is refused as ended; a fresh join to a record without a call going on starts one.
|
|
44
|
+
*/
|
|
45
|
+
const admit = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
46
|
+
const { record, member, resume } = input;
|
|
47
|
+
let call;
|
|
48
|
+
if (resume) {
|
|
49
|
+
const resumed = isCallId(resume.call) ? await lockCall(trx, resume.call) : void 0;
|
|
50
|
+
if (!resumed || resumed.ended || resumed.resource !== record.resource || resumed.record_key !== record.key) return { refused: "ended" };
|
|
51
|
+
if (resumed.expired) {
|
|
52
|
+
await endCall(trx, resumed.id);
|
|
53
|
+
return { refused: "ended" };
|
|
54
|
+
}
|
|
55
|
+
call = resumed.id;
|
|
56
|
+
} else call = await liveCall(trx, record, member, input.maxMinutes);
|
|
57
|
+
await sql`update "calls"."members" set "left_at" = now()
|
|
58
|
+
where "call_id" = ${call}::uuid and "left_at" is null and "seen_at" < now() - make_interval(secs => ${30})`.execute(trx);
|
|
59
|
+
const { rows } = await sql`select "member", "left_at" is null as present from "calls"."members"
|
|
60
|
+
where "call_id" = ${call}::uuid and ("left_at" is null or ("member" = ${member} and "removed_at" is not null))`.execute(trx);
|
|
61
|
+
const refused = admission({
|
|
62
|
+
removed: rows.some((row) => row.member === member && !row.present),
|
|
63
|
+
present: rows.filter((row) => row.present).map((row) => row.member),
|
|
64
|
+
member,
|
|
65
|
+
maxMembers: input.maxMembers
|
|
66
|
+
});
|
|
67
|
+
if (refused) return { refused };
|
|
68
|
+
return {
|
|
69
|
+
call,
|
|
70
|
+
row: await enter(trx, call, member, input.process, resume?.tracks ?? [])
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
/** The member of row `row` left, unless another process took them over; the call ends with its last member. */
|
|
74
|
+
const leave = (db, timeoutMs, row, process) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
75
|
+
const { rows } = await sql`select "call_id"::text as call from "calls"."members" where "id" = ${row}::bigint`.execute(trx);
|
|
76
|
+
if (!rows[0]) return;
|
|
77
|
+
const call = await lockCall(trx, rows[0].call);
|
|
78
|
+
if (!call || call.ended) return;
|
|
79
|
+
await sql`update "calls"."members" set "left_at" = now() where "id" = ${row}::bigint and "process" = ${process}::uuid and "left_at" is null`.execute(trx);
|
|
80
|
+
await endIfEmpty(trx, call.id);
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* Removes the member from the call `call`, and only from it: their time in it ends as removed, or, when they are not in
|
|
84
|
+
* it, a removal is kept, so a join of theirs on its way is refused. Nothing happens to a call that ended or is unknown.
|
|
85
|
+
* Whether they were in it. The call ends when they were its last member.
|
|
86
|
+
*/
|
|
87
|
+
const remove = (db, timeoutMs, callId, member) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
88
|
+
const call = isCallId(callId) ? await lockCall(trx, callId) : void 0;
|
|
89
|
+
if (!call || call.ended) return false;
|
|
90
|
+
const { rows } = await sql`update "calls"."members" set "left_at" = now(), "removed_at" = now()
|
|
91
|
+
where "call_id" = ${call.id}::uuid and "member" = ${member} and "left_at" is null returning "id"::text as id`.execute(trx);
|
|
92
|
+
if (rows.length === 0) {
|
|
93
|
+
await sql`insert into "calls"."members" ("call_id", "member", "process", "tracks", "joined_at", "seen_at", "left_at", "removed_at")
|
|
94
|
+
values (${call.id}::uuid, ${member}, ${nobody}::uuid, '[]'::jsonb, now(), now(), now(), now())`.execute(trx);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
await endIfEmpty(trx, call.id);
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
/** The tracks the member of row `row` sends now, while `process` serves them. */
|
|
101
|
+
const setTracks = async (db, row, process, tracks) => {
|
|
102
|
+
await sql`update "calls"."members" set "tracks" = ${JSON.stringify(tracks)}::jsonb
|
|
103
|
+
where "id" = ${row}::bigint and "process" = ${process}::uuid and "left_at" is null`.execute(db);
|
|
104
|
+
};
|
|
105
|
+
/** The process still hears of the members of these rows: they stay in their calls. */
|
|
106
|
+
const heartbeat = async (db, process, rows) => {
|
|
107
|
+
if (rows.length === 0) return;
|
|
108
|
+
await sql`update "calls"."members" set "seen_at" = now()
|
|
109
|
+
where "id" = any(${`{${rows.join(",")}}`}::bigint[]) and "process" = ${process}::uuid and "left_at" is null`.execute(db);
|
|
110
|
+
};
|
|
111
|
+
//#endregion
|
|
112
|
+
export { admit, endCall, heartbeat, isCallId, leave, remove, setTracks };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { writeTransaction } from "../transactions.js";
|
|
2
|
+
import { endCall } from "./call-store.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/calls/call-sweep.ts
|
|
5
|
+
/** How long a signal between processes is kept: long after it was read. */
|
|
6
|
+
const signalSeconds = 60;
|
|
7
|
+
/**
|
|
8
|
+
* Ends what should have ended, over every call, whichever process served it: the members no process heard of within
|
|
9
|
+
* `graceSeconds`, as after their socket was lost or their process stopped; the calls past their last minute; the
|
|
10
|
+
* calls nobody is in any more. A call an admission holds is left to the next sweep. Old signals go too.
|
|
11
|
+
*/
|
|
12
|
+
const sweepCalls = (db, timeoutMs) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
13
|
+
await sql`update "calls"."members" set "left_at" = now()
|
|
14
|
+
where "left_at" is null and "seen_at" < now() - make_interval(secs => ${30})`.execute(trx);
|
|
15
|
+
const { rows } = await sql`select "id"::text as id from "calls"."calls" c where "ended_at" is null
|
|
16
|
+
and ("ends_at" <= now() or not exists (select from "calls"."members" m where m."call_id" = c."id" and m."left_at" is null))
|
|
17
|
+
for update skip locked`.execute(trx);
|
|
18
|
+
if (rows.length > 0) {
|
|
19
|
+
const ended = await sql`select "id"::text as id from "calls"."calls" c where "id" = any(${`{${rows.map((row) => row.id).join(",")}}`}::uuid[])
|
|
20
|
+
and ("ends_at" <= now() or not exists (select from "calls"."members" m where m."call_id" = c."id" and m."left_at" is null))`.execute(trx);
|
|
21
|
+
for (const { id } of ended.rows) await endCall(trx, id);
|
|
22
|
+
}
|
|
23
|
+
await sql`delete from "calls"."signals" where "at" < now() - make_interval(secs => ${signalSeconds})`.execute(trx);
|
|
24
|
+
});
|
|
25
|
+
//#endregion
|
|
26
|
+
export { sweepCalls };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { recordId } from "./call-reads.js";
|
|
2
|
+
//#region src/calls/call-watch.ts
|
|
3
|
+
/**
|
|
4
|
+
* The records whose calls open pages follow on this process: each read reads all of them in one query, and tells a
|
|
5
|
+
* page only when its record's calls changed.
|
|
6
|
+
*/
|
|
7
|
+
const callWatch = (store) => {
|
|
8
|
+
const watchers = /* @__PURE__ */ new Set();
|
|
9
|
+
const read = async (some) => {
|
|
10
|
+
const records = [...new Map(some.map((watcher) => [recordId(watcher.record), watcher.record])).values()];
|
|
11
|
+
const found = await store.recordCalls(records);
|
|
12
|
+
for (const watcher of some) {
|
|
13
|
+
if (!watchers.has(watcher)) continue;
|
|
14
|
+
const items = found.get(recordId(watcher.record)) ?? [];
|
|
15
|
+
const body = JSON.stringify(items);
|
|
16
|
+
if (watcher.sent === body) continue;
|
|
17
|
+
watcher.sent = body;
|
|
18
|
+
watcher.listener(items);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
get size() {
|
|
23
|
+
return watchers.size;
|
|
24
|
+
},
|
|
25
|
+
/** Tells `listener` the record's calls now, then whenever they change, until the returned function is called. */
|
|
26
|
+
add: async (record, listener) => {
|
|
27
|
+
const watcher = {
|
|
28
|
+
record,
|
|
29
|
+
listener
|
|
30
|
+
};
|
|
31
|
+
watchers.add(watcher);
|
|
32
|
+
await read([watcher]);
|
|
33
|
+
return () => void watchers.delete(watcher);
|
|
34
|
+
},
|
|
35
|
+
read: () => read([...watchers])
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { callWatch };
|
|
@@ -1,13 +1,28 @@
|
|
|
1
|
+
import "../types.js";
|
|
1
2
|
import { SocketEvents } from "../functions/function-socket.js";
|
|
2
|
-
import { CallMember } from "./call-member.js";
|
|
3
|
+
import { CallMember, CallRecordKey } from "./call-member.js";
|
|
4
|
+
import { CallItem } from "./call-reads.js";
|
|
5
|
+
import "./calls-service-type.js";
|
|
3
6
|
//#region src/calls/calls-access.d.ts
|
|
4
|
-
/** What a function admits members to calls with. */
|
|
7
|
+
/** What a function admits members to calls with, and sees and changes them by. */
|
|
5
8
|
export type CallsAccess = {
|
|
6
9
|
/**
|
|
7
|
-
* The member's media session in the call, as the events of the socket the function accepts for them:
|
|
8
|
-
* `acceptWebSocket(await calls.join(member))` once the function decided the caller may join. The
|
|
9
|
-
*
|
|
10
|
+
* The member's media session in the call on the record, as the events of the socket the function accepts for them:
|
|
11
|
+
* return `acceptWebSocket(await calls.join(member))` once the function decided the caller may join. The caller must
|
|
12
|
+
* be signed in and able to read the record, now and at every minute's check; the browser's `joinCall` from
|
|
13
|
+
* `@protobase/client/calls` speaks to it. Rejects when the app has no `calls` configured.
|
|
10
14
|
*/
|
|
11
15
|
join: (member: CallMember) => Promise<SocketEvents>;
|
|
16
|
+
/**
|
|
17
|
+
* Takes the member out of the call `call` at once, and keeps them out of it: their sockets close, the others drop
|
|
18
|
+
* their connections to them, and their joins and resumes are refused. Whether they were in it; nothing happens to a
|
|
19
|
+
* call that ended.
|
|
20
|
+
*/
|
|
21
|
+
remove: (call: string, member: string) => Promise<boolean>;
|
|
22
|
+
/** The record's latest calls, newest first, the one going on first: with no access check, like `db`. */
|
|
23
|
+
of: (record: {
|
|
24
|
+
resource: string;
|
|
25
|
+
key: CallRecordKey;
|
|
26
|
+
}) => Promise<CallItem[]>;
|
|
12
27
|
};
|
|
13
28
|
//#endregion
|