@protobase/server 0.58.5 → 0.58.7
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/better-auth/auth-settings.d.ts +4 -0
- package/dist/better-auth/auth-tables.d.ts +5 -1
- package/dist/better-auth/auth-tables.js +10 -2
- package/dist/better-auth/create-auth.d.ts +4 -0
- package/dist/better-auth/store/drift.js +19 -4
- package/dist/better-auth/store/migrations/001-core.js +6 -4
- package/dist/better-auth/store/migrations/002-two-factor.js +2 -2
- package/dist/better-auth/store/migrations/006-organizations.js +2 -2
- package/dist/better-auth/store/migrations/migration.js +2 -1
- package/dist/better-auth/user-table.d.ts +9 -0
- package/dist/better-auth/user-table.js +3 -2
- package/dist/calls/call-member.d.ts +14 -0
- package/dist/calls/calls-access.d.ts +13 -0
- package/dist/calls/calls-access.js +25 -0
- package/dist/calls/calls-options.d.ts +33 -0
- package/dist/calls/calls-options.js +21 -0
- package/dist/calls/client-message.js +106 -0
- package/dist/calls/livekit/join-url.js +33 -0
- package/dist/calls/livekit/livekit-session.js +323 -0
- package/dist/calls/livekit/livekit-token.js +26 -0
- package/dist/calls/livekit/signal-socket.js +60 -0
- package/dist/calls/livekit/track-mapping.js +36 -0
- package/dist/calls/media-backend.js +5 -0
- package/dist/calls/member-relay.js +56 -0
- package/dist/calls/peer/peer-backend.js +131 -0
- package/dist/create-admin.d.ts +4 -1
- package/dist/create-admin.js +2 -1
- package/dist/functions/define-function.d.ts +3 -0
- package/dist/functions/routes.js +7 -3
- package/dist/index.d.ts +4 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.js +2 -1
- package/dist/package.js +1 -1
- package/dist/webhooks/attempt.js +2 -1
- package/package.json +5 -4
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { resumeCloseCode } from "../media-backend.js";
|
|
2
|
+
import { joinUrl } from "./join-url.js";
|
|
3
|
+
import { liveKitToken } from "./livekit-token.js";
|
|
4
|
+
import { openSignalSocket } from "./signal-socket.js";
|
|
5
|
+
import { grantSources, liveKitQuality, liveKitSource, remoteTracksOf } from "./track-mapping.js";
|
|
6
|
+
import { AddTrackRequest, DisconnectReason, LeaveRequest, LeaveRequest_Action, MuteTrackRequest, ParticipantInfo_State, RequestResponse_Reason, SessionDescription, SignalTarget, SyncState, TrackInfo, TrackPublishedResponse, TrackType, TrickleRequest, UpdateSubscription, UpdateTrackSettings, VideoLayer } from "@livekit/protocol";
|
|
7
|
+
//#region src/calls/livekit/livekit-session.ts
|
|
8
|
+
/** How long the media server has to accept a track before the publish is refused. */
|
|
9
|
+
const publishTimeoutMs = 1e4;
|
|
10
|
+
/** LiveKit's own ping defaults, for a resumed session, whose answer carries none. */
|
|
11
|
+
const resumePing = {
|
|
12
|
+
interval: 5,
|
|
13
|
+
timeout: 15
|
|
14
|
+
};
|
|
15
|
+
const leftReason = (leave) => {
|
|
16
|
+
if (leave.action === LeaveRequest_Action.RECONNECT) return "rejoin";
|
|
17
|
+
if (leave.reason === DisconnectReason.PARTICIPANT_REMOVED || leave.reason === DisconnectReason.DUPLICATE_IDENTITY) return "removed";
|
|
18
|
+
return "ended";
|
|
19
|
+
};
|
|
20
|
+
const syncState = (resume) => new SyncState({
|
|
21
|
+
offer: new SessionDescription({
|
|
22
|
+
type: "offer",
|
|
23
|
+
sdp: resume.offer
|
|
24
|
+
}),
|
|
25
|
+
answer: new SessionDescription({
|
|
26
|
+
type: "answer",
|
|
27
|
+
sdp: resume.answer
|
|
28
|
+
}),
|
|
29
|
+
publishTracks: resume.tracks.map((track) => new TrackPublishedResponse({
|
|
30
|
+
cid: track.cid,
|
|
31
|
+
track: new TrackInfo({
|
|
32
|
+
sid: track.sid,
|
|
33
|
+
type: track.kind === "audio" ? TrackType.AUDIO : TrackType.VIDEO
|
|
34
|
+
})
|
|
35
|
+
})),
|
|
36
|
+
subscription: new UpdateSubscription({
|
|
37
|
+
subscribe: true,
|
|
38
|
+
trackSids: []
|
|
39
|
+
})
|
|
40
|
+
});
|
|
41
|
+
const addTrackRequest = (track) => new AddTrackRequest({
|
|
42
|
+
cid: track.cid,
|
|
43
|
+
name: track.source,
|
|
44
|
+
type: track.kind === "audio" ? TrackType.AUDIO : TrackType.VIDEO,
|
|
45
|
+
source: liveKitSource(track.source),
|
|
46
|
+
width: track.width ?? 0,
|
|
47
|
+
height: track.height ?? 0,
|
|
48
|
+
layers: (track.layers ?? []).map((layer) => new VideoLayer({
|
|
49
|
+
quality: liveKitQuality(layer.quality),
|
|
50
|
+
width: layer.width,
|
|
51
|
+
height: layer.height,
|
|
52
|
+
bitrate: layer.bitrate
|
|
53
|
+
}))
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* One member's LiveKit signal session, in this process: the browser's call messages become LiveKit's signal requests,
|
|
57
|
+
* and LiveKit's answers, candidates and room changes become call messages. Protobase decides who hears what: nothing is
|
|
58
|
+
* subscribed until `hears` admits it. The session keeps nothing a new one could not rebuild from the browser's `resume`,
|
|
59
|
+
* so a member moves to another session, or process, without losing media.
|
|
60
|
+
*/
|
|
61
|
+
const liveKitSession = (backend, member, link, deps) => {
|
|
62
|
+
const hears = member.hears ?? (() => true);
|
|
63
|
+
let signal;
|
|
64
|
+
let finished = false;
|
|
65
|
+
const participants = /* @__PURE__ */ new Map();
|
|
66
|
+
const heard = /* @__PURE__ */ new Set();
|
|
67
|
+
const publishing = /* @__PURE__ */ new Map();
|
|
68
|
+
const toBrowser = (message) => link.send(message);
|
|
69
|
+
const finish = (code, reason) => {
|
|
70
|
+
if (finished) return;
|
|
71
|
+
finished = true;
|
|
72
|
+
signal?.close();
|
|
73
|
+
for (const pending of publishing.values()) pending.reject("The call session ended");
|
|
74
|
+
link.close(code, reason);
|
|
75
|
+
};
|
|
76
|
+
const leave = () => {
|
|
77
|
+
signal?.send({
|
|
78
|
+
case: "leave",
|
|
79
|
+
value: new LeaveRequest({
|
|
80
|
+
reason: DisconnectReason.CLIENT_INITIATED,
|
|
81
|
+
action: LeaveRequest_Action.DISCONNECT
|
|
82
|
+
})
|
|
83
|
+
});
|
|
84
|
+
finish(1e3, "left");
|
|
85
|
+
};
|
|
86
|
+
const updateTracks = () => {
|
|
87
|
+
const tracks = remoteTracksOf([...participants.values()]).filter(hears);
|
|
88
|
+
const added = tracks.filter((track) => !heard.has(track.sid)).map((track) => track.sid);
|
|
89
|
+
const current = new Set(tracks.map((track) => track.sid));
|
|
90
|
+
for (const sid of heard) if (!current.has(sid)) heard.delete(sid);
|
|
91
|
+
for (const sid of added) heard.add(sid);
|
|
92
|
+
if (added.length > 0) signal?.send({
|
|
93
|
+
case: "subscription",
|
|
94
|
+
value: new UpdateSubscription({
|
|
95
|
+
trackSids: added,
|
|
96
|
+
subscribe: true
|
|
97
|
+
})
|
|
98
|
+
});
|
|
99
|
+
toBrowser({
|
|
100
|
+
type: "tracks",
|
|
101
|
+
tracks
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const updateParticipants = (infos, self) => {
|
|
105
|
+
for (const info of infos) {
|
|
106
|
+
if (info.identity === self) continue;
|
|
107
|
+
if (info.state === ParticipantInfo_State.DISCONNECTED) participants.delete(info.sid);
|
|
108
|
+
else participants.set(info.sid, info);
|
|
109
|
+
}
|
|
110
|
+
updateTracks();
|
|
111
|
+
};
|
|
112
|
+
const onSignal = (message) => {
|
|
113
|
+
switch (message.case) {
|
|
114
|
+
case "join": {
|
|
115
|
+
const join = message.value;
|
|
116
|
+
signal.keepAlive(join.pingInterval || resumePing.interval, join.pingTimeout || resumePing.timeout);
|
|
117
|
+
toBrowser({
|
|
118
|
+
type: "joined",
|
|
119
|
+
mode: "sfu",
|
|
120
|
+
participant: join.participant.sid,
|
|
121
|
+
iceServers: join.iceServers.map((server) => ({
|
|
122
|
+
urls: server.urls,
|
|
123
|
+
...server.username && {
|
|
124
|
+
username: server.username,
|
|
125
|
+
credential: server.credential
|
|
126
|
+
}
|
|
127
|
+
}))
|
|
128
|
+
});
|
|
129
|
+
updateParticipants(join.otherParticipants, member.member);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
case "reconnect":
|
|
133
|
+
signal.keepAlive(resumePing.interval, resumePing.timeout);
|
|
134
|
+
toBrowser({ type: "resumed" });
|
|
135
|
+
return;
|
|
136
|
+
case "answer":
|
|
137
|
+
toBrowser({
|
|
138
|
+
type: "answer",
|
|
139
|
+
sdp: message.value.sdp,
|
|
140
|
+
id: message.value.id,
|
|
141
|
+
mids: message.value.midToTrackId
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
case "trickle":
|
|
145
|
+
toBrowser({
|
|
146
|
+
type: "candidate",
|
|
147
|
+
candidate: JSON.parse(message.value.candidateInit)
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
case "mediaSectionsRequirement":
|
|
151
|
+
toBrowser({
|
|
152
|
+
type: "sections",
|
|
153
|
+
audio: message.value.numAudios,
|
|
154
|
+
video: message.value.numVideos
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
case "update":
|
|
158
|
+
updateParticipants(message.value.participants, member.member);
|
|
159
|
+
return;
|
|
160
|
+
case "trackPublished":
|
|
161
|
+
publishing.get(message.value.cid)?.resolve(message.value.track.sid);
|
|
162
|
+
return;
|
|
163
|
+
case "requestResponse": {
|
|
164
|
+
const { request, reason, message: why } = message.value;
|
|
165
|
+
if (request.case === "addTrack" && reason !== RequestResponse_Reason.OK) publishing.get(request.value.cid)?.reject(why || RequestResponse_Reason[reason]);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
case "trackUnpublished":
|
|
169
|
+
toBrowser({
|
|
170
|
+
type: "unpublished",
|
|
171
|
+
sid: message.value.trackSid
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
case "speakersChanged":
|
|
175
|
+
toBrowser({
|
|
176
|
+
type: "speakers",
|
|
177
|
+
speakers: message.value.speakers.flatMap((speaker) => {
|
|
178
|
+
const info = participants.get(speaker.sid);
|
|
179
|
+
return info ? [{
|
|
180
|
+
member: info.identity,
|
|
181
|
+
level: speaker.active ? speaker.level : 0
|
|
182
|
+
}] : [];
|
|
183
|
+
})
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
case "leave": {
|
|
187
|
+
const leave = message.value;
|
|
188
|
+
if (leave.action === LeaveRequest_Action.RESUME) return finish(resumeCloseCode, "resume");
|
|
189
|
+
toBrowser({
|
|
190
|
+
type: "left",
|
|
191
|
+
reason: leftReason(leave)
|
|
192
|
+
});
|
|
193
|
+
return finish(1e3, "left");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const connect = async (options) => {
|
|
198
|
+
const canPublish = member.publish !== false && !(Array.isArray(member.publish) && member.publish.length === 0);
|
|
199
|
+
const token = await liveKitToken(backend, {
|
|
200
|
+
room: member.call,
|
|
201
|
+
identity: member.member,
|
|
202
|
+
canPublish,
|
|
203
|
+
...Array.isArray(member.publish) && { canPublishSources: member.publish.map((source) => grantSources[source]) }
|
|
204
|
+
});
|
|
205
|
+
signal = deps.connect({
|
|
206
|
+
url: joinUrl(backend.url.replace(/^http/, "ws"), token, options),
|
|
207
|
+
onMessage: onSignal,
|
|
208
|
+
onClose: () => finish(resumeCloseCode, "resume")
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
const publish = async (tracks) => {
|
|
212
|
+
const published = tracks.map((track) => new Promise((resolve, reject) => {
|
|
213
|
+
const timer = setTimeout(() => reject("The media server did not accept the track in time"), publishTimeoutMs);
|
|
214
|
+
const settle = () => {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
publishing.delete(track.cid);
|
|
217
|
+
};
|
|
218
|
+
publishing.set(track.cid, {
|
|
219
|
+
resolve: (sid) => (settle(), resolve({
|
|
220
|
+
cid: track.cid,
|
|
221
|
+
sid
|
|
222
|
+
})),
|
|
223
|
+
reject: (reason) => (settle(), reject(reason))
|
|
224
|
+
});
|
|
225
|
+
signal.send({
|
|
226
|
+
case: "addTrack",
|
|
227
|
+
value: addTrackRequest(track)
|
|
228
|
+
});
|
|
229
|
+
}));
|
|
230
|
+
const settled = await Promise.allSettled(published);
|
|
231
|
+
const refused = settled.find((result) => result.status === "rejected");
|
|
232
|
+
if (refused) return toBrowser({
|
|
233
|
+
type: "publish-refused",
|
|
234
|
+
reason: String(refused.reason)
|
|
235
|
+
});
|
|
236
|
+
toBrowser({
|
|
237
|
+
type: "published",
|
|
238
|
+
tracks: settled.map((result) => result.value)
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
const receive = async (message) => {
|
|
242
|
+
if (message.type === "join") return connect({});
|
|
243
|
+
if (message.type === "resume") {
|
|
244
|
+
if (!message.offer || !message.answer) {
|
|
245
|
+
toBrowser({
|
|
246
|
+
type: "left",
|
|
247
|
+
reason: "rejoin"
|
|
248
|
+
});
|
|
249
|
+
return finish(1e3, "left");
|
|
250
|
+
}
|
|
251
|
+
await connect({ resume: { participant: message.participant } });
|
|
252
|
+
signal.send({
|
|
253
|
+
case: "syncState",
|
|
254
|
+
value: syncState({
|
|
255
|
+
...message,
|
|
256
|
+
offer: message.offer,
|
|
257
|
+
answer: message.answer
|
|
258
|
+
})
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!signal || finished) return;
|
|
263
|
+
switch (message.type) {
|
|
264
|
+
case "publish": return publish(message.tracks);
|
|
265
|
+
case "offer": return signal.send({
|
|
266
|
+
case: "offer",
|
|
267
|
+
value: new SessionDescription({
|
|
268
|
+
type: "offer",
|
|
269
|
+
sdp: message.sdp,
|
|
270
|
+
id: message.id
|
|
271
|
+
})
|
|
272
|
+
});
|
|
273
|
+
case "candidate": return signal.send({
|
|
274
|
+
case: "trickle",
|
|
275
|
+
value: new TrickleRequest({
|
|
276
|
+
candidateInit: JSON.stringify(message.candidate),
|
|
277
|
+
target: SignalTarget.PUBLISHER
|
|
278
|
+
})
|
|
279
|
+
});
|
|
280
|
+
case "mute": return signal.send({
|
|
281
|
+
case: "mute",
|
|
282
|
+
value: new MuteTrackRequest({
|
|
283
|
+
sid: message.sid,
|
|
284
|
+
muted: message.muted
|
|
285
|
+
})
|
|
286
|
+
});
|
|
287
|
+
case "view": {
|
|
288
|
+
const sids = message.sids.filter((sid) => heard.has(sid));
|
|
289
|
+
if (sids.length === 0) return;
|
|
290
|
+
return signal.send({
|
|
291
|
+
case: "trackSetting",
|
|
292
|
+
value: new UpdateTrackSettings({
|
|
293
|
+
trackSids: sids,
|
|
294
|
+
disabled: message.hidden ?? false,
|
|
295
|
+
width: message.width,
|
|
296
|
+
height: message.height
|
|
297
|
+
})
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
case "leave": return leave();
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
return {
|
|
304
|
+
receive,
|
|
305
|
+
end: (how) => {
|
|
306
|
+
if (finished) return;
|
|
307
|
+
finished = true;
|
|
308
|
+
if (how === "left") signal?.send({
|
|
309
|
+
case: "leave",
|
|
310
|
+
value: new LeaveRequest({
|
|
311
|
+
reason: DisconnectReason.PARTICIPANT_REMOVED,
|
|
312
|
+
action: LeaveRequest_Action.DISCONNECT
|
|
313
|
+
})
|
|
314
|
+
});
|
|
315
|
+
signal?.close();
|
|
316
|
+
for (const pending of publishing.values()) pending.reject("The call session ended");
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
/** The LiveKit plugin: each member's signal session to a LiveKit server, self-hosted or LiveKit Cloud. */
|
|
321
|
+
const liveKitBackend = (settings, deps = { connect: openSignalSocket }) => ({ open: (member, link) => liveKitSession(settings, member, link, deps) });
|
|
322
|
+
//#endregion
|
|
323
|
+
export { liveKitBackend };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SignJWT } from "jose";
|
|
2
|
+
//#region src/calls/livekit/livekit-token.ts
|
|
3
|
+
/** How long a member's token admits a join; a resume mints a new one. */
|
|
4
|
+
const tokenSeconds = 600;
|
|
5
|
+
/**
|
|
6
|
+
* A LiveKit access token for one member of one room, signed with the backend's key: no name, metadata or data
|
|
7
|
+
* messages, since names, roles and chat stay in Protobase.
|
|
8
|
+
*/
|
|
9
|
+
const liveKitToken = async ({ apiKey, apiSecret }, grant) => {
|
|
10
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
11
|
+
const video = {
|
|
12
|
+
room: grant.room,
|
|
13
|
+
roomJoin: true,
|
|
14
|
+
canSubscribe: true,
|
|
15
|
+
canPublish: grant.canPublish,
|
|
16
|
+
canPublishData: false,
|
|
17
|
+
canUpdateOwnMetadata: false,
|
|
18
|
+
...grant.canPublishSources && { canPublishSources: grant.canPublishSources }
|
|
19
|
+
};
|
|
20
|
+
return new SignJWT({ video }).setProtectedHeader({
|
|
21
|
+
alg: "HS256",
|
|
22
|
+
typ: "JWT"
|
|
23
|
+
}).setIssuer(apiKey).setSubject(grant.identity).setNotBefore(now).setExpirationTime(now + tokenSeconds).setJti(grant.identity).sign(new TextEncoder().encode(apiSecret));
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { liveKitToken };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Ping, SignalRequest, SignalResponse } from "@livekit/protocol";
|
|
2
|
+
//#region src/calls/livekit/signal-socket.ts
|
|
3
|
+
/** A WebSocket to LiveKit's signal endpoint speaking its protobuf messages, for one member. */
|
|
4
|
+
const openSignalSocket = ({ url, onMessage, onClose }) => {
|
|
5
|
+
const socket = new WebSocket(url);
|
|
6
|
+
socket.binaryType = "arraybuffer";
|
|
7
|
+
let pinger;
|
|
8
|
+
let lastPong = Date.now();
|
|
9
|
+
let closed = false;
|
|
10
|
+
const queued = [];
|
|
11
|
+
const stop = () => {
|
|
12
|
+
if (closed) return false;
|
|
13
|
+
closed = true;
|
|
14
|
+
clearInterval(pinger);
|
|
15
|
+
return true;
|
|
16
|
+
};
|
|
17
|
+
const close = () => {
|
|
18
|
+
if (stop()) socket.close();
|
|
19
|
+
};
|
|
20
|
+
socket.onmessage = (event) => {
|
|
21
|
+
const { message } = SignalResponse.fromBinary(new Uint8Array(event.data));
|
|
22
|
+
if (message.case === "pongResp") lastPong = Date.now();
|
|
23
|
+
onMessage(message);
|
|
24
|
+
};
|
|
25
|
+
socket.onopen = () => {
|
|
26
|
+
for (const data of queued.splice(0)) socket.send(data);
|
|
27
|
+
};
|
|
28
|
+
socket.onclose = () => {
|
|
29
|
+
if (stop()) onClose();
|
|
30
|
+
};
|
|
31
|
+
socket.onerror = () => {};
|
|
32
|
+
const send = (message) => {
|
|
33
|
+
const data = new SignalRequest({ message }).toBinary();
|
|
34
|
+
if (socket.readyState === WebSocket.CONNECTING) queued.push(data);
|
|
35
|
+
else if (socket.readyState === WebSocket.OPEN) socket.send(data);
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
send,
|
|
39
|
+
keepAlive: (intervalSeconds, timeoutSeconds) => {
|
|
40
|
+
clearInterval(pinger);
|
|
41
|
+
lastPong = Date.now();
|
|
42
|
+
pinger = setInterval(() => {
|
|
43
|
+
if (Date.now() - lastPong > timeoutSeconds * 1e3) {
|
|
44
|
+
if (stop()) {
|
|
45
|
+
socket.close();
|
|
46
|
+
onClose();
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
send({
|
|
51
|
+
case: "pingReq",
|
|
52
|
+
value: new Ping({ timestamp: BigInt(Date.now()) })
|
|
53
|
+
});
|
|
54
|
+
}, intervalSeconds * 1e3);
|
|
55
|
+
},
|
|
56
|
+
close
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
export { openSignalSocket };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ParticipantInfo_State, TrackSource, TrackType, VideoQuality } from "@livekit/protocol";
|
|
2
|
+
//#region src/calls/livekit/track-mapping.ts
|
|
3
|
+
const sources = {
|
|
4
|
+
camera: TrackSource.CAMERA,
|
|
5
|
+
microphone: TrackSource.MICROPHONE,
|
|
6
|
+
screen: TrackSource.SCREEN_SHARE,
|
|
7
|
+
"screen-audio": TrackSource.SCREEN_SHARE_AUDIO
|
|
8
|
+
};
|
|
9
|
+
/** The names LiveKit's token grants give each source in `canPublishSources`. */
|
|
10
|
+
const grantSources = {
|
|
11
|
+
camera: "camera",
|
|
12
|
+
microphone: "microphone",
|
|
13
|
+
screen: "screen_share",
|
|
14
|
+
"screen-audio": "screen_share_audio"
|
|
15
|
+
};
|
|
16
|
+
const liveKitSource = (source) => sources[source];
|
|
17
|
+
/** A LiveKit source as the call protocol names it; an unknown one counts as the camera or microphone of its kind. */
|
|
18
|
+
const callSource = (source, type) => {
|
|
19
|
+
return Object.keys(sources).find((name) => sources[name] === source) ?? (type === TrackType.AUDIO ? "microphone" : "camera");
|
|
20
|
+
};
|
|
21
|
+
const liveKitQuality = (quality) => ({
|
|
22
|
+
low: VideoQuality.LOW,
|
|
23
|
+
medium: VideoQuality.MEDIUM,
|
|
24
|
+
high: VideoQuality.HIGH
|
|
25
|
+
})[quality];
|
|
26
|
+
const remoteTrack = (member, track) => ({
|
|
27
|
+
sid: track.sid,
|
|
28
|
+
member,
|
|
29
|
+
kind: track.type === TrackType.AUDIO ? "audio" : "video",
|
|
30
|
+
source: callSource(track.source, track.type),
|
|
31
|
+
muted: track.muted
|
|
32
|
+
});
|
|
33
|
+
/** The audio and video tracks of the participants that are still in the room, each under its participant's identity. */
|
|
34
|
+
const remoteTracksOf = (participants) => participants.filter((participant) => participant.state !== ParticipantInfo_State.DISCONNECTED).flatMap((participant) => participant.tracks.filter((track) => track.type !== TrackType.DATA).map((track) => remoteTrack(participant.identity, track)));
|
|
35
|
+
//#endregion
|
|
36
|
+
export { callSource, grantSources, liveKitQuality, liveKitSource, remoteTracksOf };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { parseClientMessage } from "./client-message.js";
|
|
2
|
+
//#region src/calls/member-relay.ts
|
|
3
|
+
/** The close code of a socket whose caller Protobase refused at a recheck: the member leaves the call too. */
|
|
4
|
+
const refusedCloseCode = 1008;
|
|
5
|
+
const mayPublish = (member, tracks) => member.publish === true || Array.isArray(member.publish) && tracks.every((track) => member.publish.includes(track.source));
|
|
6
|
+
/**
|
|
7
|
+
* One member's socket, relayed to the call's media plugin: the relay reads and checks the browser's messages, refuses a
|
|
8
|
+
* publish the member may not make, opens the member's session at their `join` or `resume` and tells it how the socket
|
|
9
|
+
* ended. The plugin only ever sees well-formed messages of a member Protobase admitted.
|
|
10
|
+
*/
|
|
11
|
+
const memberRelay = (backend, member) => {
|
|
12
|
+
let browser;
|
|
13
|
+
let session;
|
|
14
|
+
let finished = false;
|
|
15
|
+
const send = (message) => void browser.send(JSON.stringify(message));
|
|
16
|
+
const close = (code, reason) => {
|
|
17
|
+
if (finished) return;
|
|
18
|
+
finished = true;
|
|
19
|
+
session?.end(code === 1e3 || code === refusedCloseCode ? "left" : "lost");
|
|
20
|
+
browser.close(code, reason);
|
|
21
|
+
};
|
|
22
|
+
const relay = async (message) => {
|
|
23
|
+
if (message.type === "join" || message.type === "resume") {
|
|
24
|
+
if (session) return close(refusedCloseCode, "The call session already started");
|
|
25
|
+
session = backend.open(member, {
|
|
26
|
+
send,
|
|
27
|
+
close
|
|
28
|
+
});
|
|
29
|
+
return session.receive(message);
|
|
30
|
+
}
|
|
31
|
+
if (!session) return close(refusedCloseCode, "Join or resume the call first");
|
|
32
|
+
if (message.type === "publish" && !mayPublish(member, message.tracks)) return send({
|
|
33
|
+
type: "publish-refused",
|
|
34
|
+
reason: "The member may not publish these tracks"
|
|
35
|
+
});
|
|
36
|
+
await session.receive(message);
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
onOpen: (socket) => {
|
|
40
|
+
browser = socket;
|
|
41
|
+
},
|
|
42
|
+
onMessage: async (data) => {
|
|
43
|
+
if (finished) return;
|
|
44
|
+
const message = parseClientMessage(data);
|
|
45
|
+
if (!message) return close(refusedCloseCode, "Not a call message");
|
|
46
|
+
await relay(message);
|
|
47
|
+
},
|
|
48
|
+
onClose: (code) => {
|
|
49
|
+
if (finished) return;
|
|
50
|
+
finished = true;
|
|
51
|
+
session?.end(code === refusedCloseCode ? "left" : "lost");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
export { memberRelay };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
//#region src/calls/peer/peer-backend.ts
|
|
2
|
+
/** How long a member whose socket was lost stays in the call for the others, waiting for their browser to resume. */
|
|
3
|
+
const awayMs = 3e4;
|
|
4
|
+
/**
|
|
5
|
+
* The peer plugin: no media server. Protobase passes each member's descriptions and candidates to the member they name,
|
|
6
|
+
* and tells every member who else is in the call and which of their tracks they hear; the browsers send their media to
|
|
7
|
+
* each other directly. Members meet only within this process, which keeps who is in each call.
|
|
8
|
+
*/
|
|
9
|
+
const peerBackend = (settings) => {
|
|
10
|
+
const calls = /* @__PURE__ */ new Map();
|
|
11
|
+
const membersOf = (call) => [...calls.get(call)?.values() ?? []];
|
|
12
|
+
const announce = (call) => {
|
|
13
|
+
const present = membersOf(call);
|
|
14
|
+
for (const viewer of present) {
|
|
15
|
+
if (viewer.away) continue;
|
|
16
|
+
const others = present.filter((other) => other !== viewer);
|
|
17
|
+
const hears = viewer.member.hears ?? (() => true);
|
|
18
|
+
viewer.link.send({
|
|
19
|
+
type: "peers",
|
|
20
|
+
members: others.map((other) => other.member.member)
|
|
21
|
+
});
|
|
22
|
+
viewer.link.send({
|
|
23
|
+
type: "tracks",
|
|
24
|
+
tracks: others.flatMap((other) => [...other.tracks.values()]).filter(hears)
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const remove = (self) => {
|
|
29
|
+
const call = calls.get(self.member.call);
|
|
30
|
+
if (call?.get(self.member.member) !== self) return;
|
|
31
|
+
clearTimeout(self.away);
|
|
32
|
+
call.delete(self.member.member);
|
|
33
|
+
if (call.size === 0) calls.delete(self.member.call);
|
|
34
|
+
announce(self.member.call);
|
|
35
|
+
};
|
|
36
|
+
const open = (member, link) => {
|
|
37
|
+
const self = {
|
|
38
|
+
member,
|
|
39
|
+
link,
|
|
40
|
+
tracks: /* @__PURE__ */ new Map()
|
|
41
|
+
};
|
|
42
|
+
const enter = () => {
|
|
43
|
+
const call = calls.get(member.call) ?? /* @__PURE__ */ new Map();
|
|
44
|
+
calls.set(member.call, call);
|
|
45
|
+
const before = call.get(member.member);
|
|
46
|
+
call.set(member.member, self);
|
|
47
|
+
if (before) {
|
|
48
|
+
clearTimeout(before.away);
|
|
49
|
+
if (!before.away) {
|
|
50
|
+
before.link.send({
|
|
51
|
+
type: "left",
|
|
52
|
+
reason: "removed"
|
|
53
|
+
});
|
|
54
|
+
before.link.close(1e3, "left");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const receive = (message) => {
|
|
59
|
+
switch (message.type) {
|
|
60
|
+
case "join":
|
|
61
|
+
case "resume":
|
|
62
|
+
if (message.type === "resume") for (const track of message.tracks) self.tracks.set(track.sid, {
|
|
63
|
+
sid: track.sid,
|
|
64
|
+
member: member.member,
|
|
65
|
+
kind: track.kind,
|
|
66
|
+
source: track.source,
|
|
67
|
+
muted: false
|
|
68
|
+
});
|
|
69
|
+
enter();
|
|
70
|
+
link.send(message.type === "join" ? {
|
|
71
|
+
type: "joined",
|
|
72
|
+
mode: "peer",
|
|
73
|
+
participant: member.member,
|
|
74
|
+
iceServers: settings.iceServers ?? []
|
|
75
|
+
} : { type: "resumed" });
|
|
76
|
+
return announce(member.call);
|
|
77
|
+
case "publish":
|
|
78
|
+
for (const track of message.tracks) self.tracks.set(track.cid, {
|
|
79
|
+
sid: track.cid,
|
|
80
|
+
member: member.member,
|
|
81
|
+
kind: track.kind,
|
|
82
|
+
source: track.source,
|
|
83
|
+
muted: false
|
|
84
|
+
});
|
|
85
|
+
link.send({
|
|
86
|
+
type: "published",
|
|
87
|
+
tracks: message.tracks.map((track) => ({
|
|
88
|
+
cid: track.cid,
|
|
89
|
+
sid: track.cid
|
|
90
|
+
}))
|
|
91
|
+
});
|
|
92
|
+
return announce(member.call);
|
|
93
|
+
case "unpublish":
|
|
94
|
+
self.tracks.delete(message.sid);
|
|
95
|
+
return announce(member.call);
|
|
96
|
+
case "mute": {
|
|
97
|
+
const track = self.tracks.get(message.sid);
|
|
98
|
+
if (!track) return;
|
|
99
|
+
track.muted = message.muted;
|
|
100
|
+
return announce(member.call);
|
|
101
|
+
}
|
|
102
|
+
case "signal": {
|
|
103
|
+
const to = calls.get(member.call)?.get(message.to);
|
|
104
|
+
if (!to || to.away || to === self) return;
|
|
105
|
+
to.link.send({
|
|
106
|
+
type: "signal",
|
|
107
|
+
from: member.member,
|
|
108
|
+
...message.description && { description: message.description },
|
|
109
|
+
...message.candidate && { candidate: message.candidate }
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
case "leave": return link.close(1e3, "left");
|
|
114
|
+
case "offer":
|
|
115
|
+
case "candidate":
|
|
116
|
+
case "view": return;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
receive,
|
|
121
|
+
end: (how) => {
|
|
122
|
+
if (how === "left") return remove(self);
|
|
123
|
+
if (calls.get(member.call)?.get(member.member) !== self) return;
|
|
124
|
+
self.away = setTimeout(() => remove(self), awayMs);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
return { open };
|
|
129
|
+
};
|
|
130
|
+
//#endregion
|
|
131
|
+
export { peerBackend };
|
package/dist/create-admin.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { CommentUsers } from "./comments/comment-users.js";
|
|
|
9
9
|
import { HistoryOptions } from "./history/options.js";
|
|
10
10
|
import { WebhooksOptions } from "./webhooks/table/options.js";
|
|
11
11
|
import { NotificationsOptions } from "./notifications/options.js";
|
|
12
|
+
import { CallsOptions } from "./calls/calls-options.js";
|
|
12
13
|
import { SubscriptionDefinition } from "./webhooks/subscription.js";
|
|
13
14
|
import { FilesOptions } from "./files/options.js";
|
|
14
15
|
import { Difficulty } from "./forms/challenge.js";
|
|
@@ -54,6 +55,8 @@ export type CreateAdminInput = {
|
|
|
54
55
|
realtime?: RealtimeOptions;
|
|
55
56
|
/** Each user's devices and the endpoints that register them, from `notifications: {}` (see `NotificationsOptions`). */
|
|
56
57
|
notifications?: NotificationsOptions;
|
|
58
|
+
/** The media server behind the calls functions admit members to with `calls.join` (see `CallsOptions`). */
|
|
59
|
+
calls?: CallsOptions;
|
|
57
60
|
/**
|
|
58
61
|
* The app's iOS and Android build (see `MobileConfig`): the API answers its Capacitor origins through CORS, and sign-in
|
|
59
62
|
* at `/api/auth` trusts the Capacitor origin a request names, without CORS, as the app calls it through native HTTP.
|
|
@@ -93,5 +96,5 @@ export type CreateAdminInput = {
|
|
|
93
96
|
upgradeWebSocket?: UpgradeWebSocket;
|
|
94
97
|
};
|
|
95
98
|
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
96
|
-
export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, secret, files: filesOptions, events, history: historyOptions, jobs, subscriptions, webhooks: webhooksOptions, realtime, notifications, mobile, app: identity, icons, bundlePlugins, fieldTypes, options, formsDifficulty, commentUsers: givenCommentUsers, verifyToken, upgradeWebSocket }: CreateAdminInput) => Admin;
|
|
99
|
+
export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, secret, files: filesOptions, events, history: historyOptions, jobs, subscriptions, webhooks: webhooksOptions, realtime, notifications, calls, mobile, app: identity, icons, bundlePlugins, fieldTypes, options, formsDifficulty, commentUsers: givenCommentUsers, verifyToken, upgradeWebSocket }: CreateAdminInput) => Admin;
|
|
97
100
|
//#endregion
|
package/dist/create-admin.js
CHANGED
|
@@ -108,7 +108,7 @@ const declaredRoles = (auth, roles) => {
|
|
|
108
108
|
return names.length > 0 ? [...new Set(names)] : void 0;
|
|
109
109
|
};
|
|
110
110
|
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
111
|
-
const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, bottomBars = [], db, authenticate, auth, functions = {}, secret, files: filesOptions, events, history: historyOptions, jobs = [], subscriptions = [], webhooks: webhooksOptions, realtime, notifications, mobile, app: identity, icons, bundlePlugins = [], fieldTypes = {}, options = {}, formsDifficulty, commentUsers: givenCommentUsers, verifyToken, upgradeWebSocket }) => {
|
|
111
|
+
const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, bottomBars = [], db, authenticate, auth, functions = {}, secret, files: filesOptions, events, history: historyOptions, jobs = [], subscriptions = [], webhooks: webhooksOptions, realtime, notifications, calls, mobile, app: identity, icons, bundlePlugins = [], fieldTypes = {}, options = {}, formsDifficulty, commentUsers: givenCommentUsers, verifyToken, upgradeWebSocket }) => {
|
|
112
112
|
const basePath = options.basePath ?? "/api/v1";
|
|
113
113
|
const appModels = appResources.map((resource) => resource.toModel());
|
|
114
114
|
const withComments = appModels.some((model) => model.comments);
|
|
@@ -388,6 +388,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
388
388
|
wake: () => wakeDispatch(deps),
|
|
389
389
|
upgradeWebSocket,
|
|
390
390
|
upgradeAuthenticate,
|
|
391
|
+
...calls && { calls },
|
|
391
392
|
report
|
|
392
393
|
}));
|
|
393
394
|
const verifyGoTrue = auth ? jwtVerifier(goTrueTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens"));
|