@protobase/server 0.58.4 → 0.58.6

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.
@@ -0,0 +1,14 @@
1
+ import { CallRemoteTrack, CallTrackSource } from "@protobase/schema/internal";
2
+ //#region src/calls/call-member.d.ts
3
+ /** One member admitted to one call, with what they may send and hear. */
4
+ export type CallMember = {
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;
9
+ /** Whether the member may send media, or only these sources; nothing when `false`. */
10
+ publish: boolean | CallTrackSource[];
11
+ /** Which of the other members' tracks the member hears; every one by default. */
12
+ hears?: (track: CallRemoteTrack) => boolean;
13
+ };
14
+ //#endregion
@@ -0,0 +1,13 @@
1
+ import { SocketEvents } from "../functions/function-socket.js";
2
+ import { CallMember } from "./call-member.js";
3
+ //#region src/calls/calls-access.d.ts
4
+ /** What a function admits members to calls with. */
5
+ export type CallsAccess = {
6
+ /**
7
+ * The member's media session in the call, as the events of the socket the function accepts for them: return
8
+ * `acceptWebSocket(await calls.join(member))` once the function decided the caller may join. The browser's
9
+ * `joinCall` from `@protobase/client/calls` speaks to it. Rejects when the app has no `calls` configured.
10
+ */
11
+ join: (member: CallMember) => Promise<SocketEvents>;
12
+ };
13
+ //#endregion
@@ -0,0 +1,25 @@
1
+ //#region src/calls/calls-access.ts
2
+ const checkMember = ({ call, member }) => {
3
+ if (typeof call !== "string" || call.length === 0) throw new TypeError("calls.join needs the call, a non-empty string");
4
+ if (typeof member !== "string" || member.length === 0) throw new TypeError("calls.join needs the member, a non-empty string");
5
+ };
6
+ const loadBackend = async (settings) => {
7
+ if (settings.kind === "livekit") return (await import("./livekit/livekit-session.js")).liveKitBackend(settings);
8
+ return (await import("./peer/peer-backend.js")).peerBackend(settings);
9
+ };
10
+ /**
11
+ * Calls as a function sees them: each member's socket goes through the relay to the configured plugin, made once, at
12
+ * the first join, so an app without calls loads none of their code.
13
+ */
14
+ const callsAccess = (options) => {
15
+ let backend;
16
+ return { join: async (member) => {
17
+ if (!options) throw new Error("This app has no calls: configure calls: { backend } in protobase.config.ts");
18
+ checkMember(member);
19
+ backend ??= loadBackend(options.backend);
20
+ const [{ memberRelay }, plugin] = await Promise.all([import("./member-relay.js"), backend]);
21
+ return memberRelay(plugin, member);
22
+ } };
23
+ };
24
+ //#endregion
25
+ export { callsAccess };
@@ -0,0 +1,33 @@
1
+ //#region src/calls/calls-options.d.ts
2
+ /** A LiveKit server, self-hosted or LiveKit Cloud, that carries the calls' media. */
3
+ export type LiveKitBackend = {
4
+ kind: 'livekit';
5
+ /** Its address, such as `wss://livekit.example.com` or `ws://127.0.0.1:7880`. */
6
+ url: string;
7
+ /** The API key and secret Protobase signs each member's LiveKit token with. */
8
+ apiKey: string;
9
+ apiSecret: string;
10
+ };
11
+ /** An ICE server the members' browsers reach each other through, such as a STUN or TURN server. */
12
+ export type CallIceServer = {
13
+ urls: string[];
14
+ username?: string;
15
+ credential?: string;
16
+ };
17
+ /**
18
+ * No media server: each member's browser sends its media to every other member's, and Protobase passes their offers,
19
+ * answers and candidates between them. For small calls; members meet only on the same process.
20
+ */
21
+ export type PeerBackend = {
22
+ kind: 'peer';
23
+ iceServers?: CallIceServer[];
24
+ };
25
+ /** The media plugin a call's members send and receive their media through. */
26
+ export type CallBackend = LiveKitBackend | PeerBackend;
27
+ /** Calls: the media plugin behind them. Without `calls`, an app loads none of the calls code. */
28
+ export type CallsOptions = {
29
+ backend: CallBackend;
30
+ };
31
+ /** The options, or an error naming what is wrong with them: the backend's kind and each of its settings. */
32
+ export declare const checkCallsOptions: (options: CallsOptions) => CallsOptions;
33
+ //#endregion
@@ -0,0 +1,21 @@
1
+ //#region src/calls/calls-options.ts
2
+ const nonEmpty = (value) => typeof value === "string" && value.length > 0;
3
+ /** The options, or an error naming what is wrong with them: the backend's kind and each of its settings. */
4
+ const checkCallsOptions = (options) => {
5
+ const backend = options?.backend;
6
+ if (backend?.kind === "peer") {
7
+ const servers = backend.iceServers;
8
+ if (servers !== void 0 && (!Array.isArray(servers) || servers.some((server) => !Array.isArray(server?.urls) || !server.urls.every(nonEmpty)))) throw new Error("calls.backend.iceServers must be a list of { urls: string[], username?, credential? }");
9
+ return options;
10
+ }
11
+ if (backend?.kind !== "livekit") throw new Error(`calls.backend.kind must be 'livekit' or 'peer', not ${JSON.stringify(backend?.kind)}`);
12
+ for (const key of [
13
+ "url",
14
+ "apiKey",
15
+ "apiSecret"
16
+ ]) if (!nonEmpty(backend[key])) throw new Error(`calls.backend.${key} must be a non-empty string, such as from an environment variable`);
17
+ if (!/^(wss?|https?):\/\//.test(backend.url)) throw new Error(`calls.backend.url must start with wss://, ws://, https:// or http://, not ${backend.url}`);
18
+ return options;
19
+ };
20
+ //#endregion
21
+ export { checkCallsOptions };
@@ -0,0 +1,106 @@
1
+ import { z } from "zod";
2
+ //#region src/calls/client-message.ts
3
+ const id = z.string().min(1).max(256);
4
+ const sdp = z.string().min(1).max(262144);
5
+ const kind = z.enum(["audio", "video"]);
6
+ const size = z.number().int().min(0).max(16384);
7
+ const layer = z.strictObject({
8
+ quality: z.enum([
9
+ "low",
10
+ "medium",
11
+ "high"
12
+ ]),
13
+ width: size,
14
+ height: size,
15
+ bitrate: z.number().int().min(0).max(1e8)
16
+ });
17
+ const source = z.enum([
18
+ "camera",
19
+ "microphone",
20
+ "screen",
21
+ "screen-audio"
22
+ ]);
23
+ const track = z.strictObject({
24
+ cid: id,
25
+ kind,
26
+ source,
27
+ width: size.optional(),
28
+ height: size.optional(),
29
+ layers: z.array(layer).max(3).optional()
30
+ });
31
+ const candidate = z.strictObject({
32
+ candidate: z.string().max(4096),
33
+ sdpMid: z.string().max(64).nullable().optional(),
34
+ sdpMLineIndex: z.number().int().min(0).max(1024).nullable().optional(),
35
+ usernameFragment: z.string().max(256).nullable().optional()
36
+ });
37
+ const description = z.strictObject({
38
+ type: z.enum(["offer", "answer"]),
39
+ sdp
40
+ });
41
+ const message = z.discriminatedUnion("type", [
42
+ z.strictObject({ type: z.literal("join") }),
43
+ z.strictObject({
44
+ type: z.literal("resume"),
45
+ participant: id,
46
+ offer: sdp.optional(),
47
+ answer: sdp.optional(),
48
+ tracks: z.array(z.strictObject({
49
+ cid: id,
50
+ sid: id,
51
+ kind,
52
+ source
53
+ })).max(16)
54
+ }),
55
+ z.strictObject({
56
+ type: z.literal("publish"),
57
+ tracks: z.array(track).min(1).max(4)
58
+ }),
59
+ z.strictObject({
60
+ type: z.literal("offer"),
61
+ sdp,
62
+ id: z.number().int().min(0)
63
+ }),
64
+ z.strictObject({
65
+ type: z.literal("candidate"),
66
+ candidate
67
+ }),
68
+ z.strictObject({
69
+ type: z.literal("unpublish"),
70
+ sid: id
71
+ }),
72
+ z.strictObject({
73
+ type: z.literal("mute"),
74
+ sid: id,
75
+ muted: z.boolean()
76
+ }),
77
+ z.strictObject({
78
+ type: z.literal("signal"),
79
+ to: id,
80
+ description: description.optional(),
81
+ candidate: candidate.optional()
82
+ }),
83
+ z.strictObject({
84
+ type: z.literal("view"),
85
+ sids: z.array(id).min(1).max(256),
86
+ width: size,
87
+ height: size,
88
+ hidden: z.boolean().optional()
89
+ }),
90
+ z.strictObject({ type: z.literal("leave") })
91
+ ]);
92
+ /** A browser's message, or `undefined` when it is no message of the call protocol. */
93
+ const parseClientMessage = (data) => {
94
+ if (typeof data !== "string") return void 0;
95
+ let json;
96
+ try {
97
+ json = JSON.parse(data);
98
+ } catch (error) {
99
+ if (error instanceof SyntaxError) return void 0;
100
+ throw error;
101
+ }
102
+ const parsed = message.safeParse(json);
103
+ return parsed.success ? parsed.data : void 0;
104
+ };
105
+ //#endregion
106
+ export { parseClientMessage };
@@ -0,0 +1,33 @@
1
+ import { ClientInfo, ClientInfo_SDK, ConnectionSettings, JoinRequest, WrappedJoinRequest, WrappedJoinRequest_Compression } from "@livekit/protocol";
2
+ /** The bridge as LiveKit's client info names it. */
3
+ const clientInfo = () => new ClientInfo({
4
+ sdk: ClientInfo_SDK.UNKNOWN,
5
+ protocol: 17,
6
+ version: "protobase-calls-1"
7
+ });
8
+ /**
9
+ * The single peer connection signal endpoint (`/rtc/v1`) with the member's token and join request. The request is
10
+ * standard base64 with `+` and `/` replaced and the `=` padding kept, which LiveKit's decoder needs.
11
+ */
12
+ const joinUrl = (base, token, { resume }) => {
13
+ const join = new JoinRequest({
14
+ clientInfo: clientInfo(),
15
+ connectionSettings: new ConnectionSettings({
16
+ autoSubscribe: false,
17
+ adaptiveStream: false
18
+ }),
19
+ reconnect: resume !== void 0,
20
+ participantSid: resume?.participant ?? ""
21
+ });
22
+ const wrapped = new WrappedJoinRequest({
23
+ compression: WrappedJoinRequest_Compression.NONE,
24
+ joinRequest: join.toBinary()
25
+ });
26
+ const encoded = Buffer.from(wrapped.toBinary()).toString("base64").replaceAll("+", "-").replaceAll("/", "_");
27
+ const url = new URL("rtc/v1", `${base.replace(/\/+$/, "")}/`);
28
+ url.searchParams.set("access_token", token);
29
+ url.searchParams.set("join_request", encoded);
30
+ return url.href;
31
+ };
32
+ //#endregion
33
+ export { joinUrl };
@@ -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,5 @@
1
+ //#region src/calls/media-backend.ts
2
+ /** The close code that has the browser resume on a new socket: the server lost its session, not the member. */
3
+ const resumeCloseCode = 4e3;
4
+ //#endregion
5
+ export { resumeCloseCode };
@@ -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 };
@@ -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
@@ -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"));
@@ -2,6 +2,7 @@ import { DecryptField } from "../encryption/decrypt-field-types.js";
2
2
  import { Session } from "../types.js";
3
3
  import { Caller } from "../api-keys/resolve-caller.js";
4
4
  import { SocketEvents } from "./function-socket.js";
5
+ import { CallsAccess } from "../calls/calls-access.js";
5
6
  import { FunctionRecords } from "./records.js";
6
7
  import { Capability } from "@protobase/schema";
7
8
  import { Db } from "@protobase/query";
@@ -22,6 +23,8 @@ export type PublicFunctionContext = {
22
23
  * not repeated unless it passes them as `recheck`. `maxMessageBytes` lowers the largest message the socket takes.
23
24
  */
24
25
  acceptWebSocket: (events: SocketEvents) => Response;
26
+ /** Admits a member to a call's media, over the socket the function accepts: see `CallsAccess`. */
27
+ calls: CallsAccess;
25
28
  };
26
29
  /** Who called a function, and the app's resources as them. */
27
30
  export type FunctionCaller = {
@@ -3,6 +3,7 @@ import { namingTimeouts } from "../statement-timeout.js";
3
3
  import { auditOrigin } from "../audit/origin.js";
4
4
  import { invokeFunction } from "./define-function.js";
5
5
  import { runInBackground } from "../background-work.js";
6
+ import { callsAccess } from "../calls/calls-access.js";
6
7
  import { socketAcceptor } from "./accept-socket.js";
7
8
  import { functionGate, requireGate } from "./function-gate.js";
8
9
  import { sameCaller } from "./same-caller.js";
@@ -23,7 +24,8 @@ const isUpgrade = (request) => request.headers.get("upgrade")?.toLowerCase() ===
23
24
  * A function may answer an upgrade with a socket, only once it returned. While it is open, its caller is checked again
24
25
  * every minute: a protected function's always, a public one's once `caller()` resolved.
25
26
  */
26
- const functionRoutes = ({ basePath, functions, authenticate, access, db, records, decryptField, onError, wake, upgradeWebSocket, upgradeAuthenticate = authenticate, report }) => {
27
+ const functionRoutes = ({ basePath, functions, authenticate, access, db, records, decryptField, onError, wake, upgradeWebSocket, upgradeAuthenticate = authenticate, calls: callsOptions, report }) => {
28
+ const calls = callsAccess(callsOptions);
27
29
  const app = new Hono().basePath(basePath);
28
30
  app.onError(onError);
29
31
  const sessionOf = (request) => isUpgrade(request) ? upgradeAuthenticate(request) : authenticate(request);
@@ -67,7 +69,8 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
67
69
  name,
68
70
  db,
69
71
  caller: resolveCaller,
70
- acceptWebSocket: acceptor.accept
72
+ acceptWebSocket: acceptor.accept,
73
+ calls
71
74
  };
72
75
  return acceptor.answer(name, await invokeFunction(name, fn, request, context));
73
76
  }
@@ -95,7 +98,8 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
95
98
  ...caller,
96
99
  caller: async () => caller,
97
100
  decryptField: decryptField(reader),
98
- acceptWebSocket: acceptor.accept
101
+ acceptWebSocket: acceptor.accept,
102
+ calls
99
103
  };
100
104
  return acceptor.answer(name, await invokeFunction(name, fn, request, context));
101
105
  }));
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ import { DeviceStore } from "./notifications/device-store.js";
21
21
  import { HistoryStore, maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
22
22
  import { PushAnswer, PushSender } from "./notifications/sender.js";
23
23
  import { DeviceColumn, HistoryColumn, NotificationsOptions } from "./notifications/options.js";
24
+ import { CallBackend, CallsOptions, LiveKitBackend } from "./calls/calls-options.js";
24
25
  import { EventData, RunContext, SubscriptionDefinition, SubscriptionEvent, SubscriptionOptions, subscription } from "./webhooks/subscription.js";
25
26
  import { Sharp, renderVariant, thumbHash } from "./files/images.js";
26
27
  import { FileProvider, FileStore, StoredObject } from "./files/store.js";
@@ -28,6 +29,8 @@ import { FilesOptions } from "./files/options.js";
28
29
  import { ApiKeys, ApiKeysOptions, apiKeySettings, apiKeyVariables } from "./api-keys/api-key-settings.js";
29
30
  import { Caller, CallerOptions, apiKeyCaller } from "./api-keys/resolve-caller.js";
30
31
  import { FunctionSocket, SocketEvents } from "./functions/function-socket.js";
32
+ import { CallMember } from "./calls/call-member.js";
33
+ import { CallsAccess } from "./calls/calls-access.js";
31
34
  import { ResourceSource, configExports } from "./resource-source.js";
32
35
  import { ErrorReportingOptions, ErrorReportingSettings, errorReportingSettings, errorReportingVariable } from "./error-reporting/error-reporting-settings.js";
33
36
  import { ChatMessage, ChatTextPart, Completion, CompletionOptions, ModelError, ModelSettings, ReasoningDetail, ReasoningEffort, ToolCall, ToolSpec, streamChatCompletion } from "./assistant/chat-completions.js";
@@ -171,4 +174,4 @@ import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/ru
171
174
  import { jobsDrainApp } from "./jobs/drain-route.js";
172
175
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
173
176
  import { AdminAuth, CreateAuthOptions, UserCreatedHook, normalizeRoles } from "./better-auth/create-auth.js";
174
- export { type Admin, type AdminAuth, type AdminOptions, type AggregateParams, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type ArgumentsPayload, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type AuthHooks, type AuthSettings, type AuthenticateSettings, type Authenticator, type BaseToolContext, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentToolsInput, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DecryptField, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueJob, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldDecrypter, type FieldReader, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileProviderSettings, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, type HookContext, HttpProblem, type IssueTokenOptions, type JobContext, type JobDeclaration, type JobDefinition, type JobName, type JobOptions, type JobPayload, type JobRun, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpApprovals, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, type Membership, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewDevice, type NewNotification, type NewOrganization, type NewUser, type NotificationRecipients, NotificationSendError, type NotificationsOptions, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type RealtimeOptions, type RealtimePrune, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordGroups, type RecordKey, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type SendNotification, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredNotification, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolComment, type ToolCommentPage, type ToolComments, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
177
+ export { type Admin, type AdminAuth, type AdminOptions, type AggregateParams, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type ArgumentsPayload, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type AuthHooks, type AuthSettings, type AuthenticateSettings, type Authenticator, type BaseToolContext, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type CallBackend, type CallMember, type Caller, type CallerOptions, type CallsAccess, type CallsOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentToolsInput, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DecryptField, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueJob, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldDecrypter, type FieldReader, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileProviderSettings, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, type HookContext, HttpProblem, type IssueTokenOptions, type JobContext, type JobDeclaration, type JobDefinition, type JobName, type JobOptions, type JobPayload, type JobRun, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LiveKitBackend, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpApprovals, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, type Membership, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewDevice, type NewNotification, type NewOrganization, type NewUser, type NotificationRecipients, NotificationSendError, type NotificationsOptions, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type RealtimeOptions, type RealtimePrune, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordGroups, type RecordKey, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type SendNotification, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredNotification, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolComment, type ToolCommentPage, type ToolComments, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
@@ -1,4 +1,5 @@
1
1
  import { checkNotificationsOptions } from "./notifications/options.js";
2
+ import { checkCallsOptions } from "./calls/calls-options.js";
2
3
  import { isSubscription } from "./webhooks/subscription.js";
3
4
  import { appPool, longQueryPool } from "./app-pool.js";
4
5
  import { AppAuthInput, appAuthOptions, buildAppAuth } from "./better-auth/auth-settings.js";
@@ -10,4 +11,4 @@ import { fileProviderOf } from "./files/provider-settings.js";
10
11
  import { declaredJob } from "./jobs/declared-job.js";
11
12
  import { AuthPlugins, buildAuth, createAuth, loadAuthPlugins } from "./better-auth/create-auth.js";
12
13
  import { webhooksModule } from "./webhooks/table/webhooks-module.js";
13
- export { type AppAuthInput, type AuthPlugins, appAuthOptions, appPool, betterAuthAuthenticator, buildAppAuth, buildAuth, buildAuthenticator, checkNotificationsOptions, createAuth, declaredJob, fileProviderOf, isSubscription, loadAuthPlugins, localFiles, longQueryPool, s3Files, webhooksModule };
14
+ export { type AppAuthInput, type AuthPlugins, appAuthOptions, appPool, betterAuthAuthenticator, buildAppAuth, buildAuth, buildAuthenticator, checkCallsOptions, checkNotificationsOptions, createAuth, declaredJob, fileProviderOf, isSubscription, loadAuthPlugins, localFiles, longQueryPool, s3Files, webhooksModule };
package/dist/internal.js CHANGED
@@ -10,4 +10,5 @@ import { declaredJob } from "./jobs/declared-job.js";
10
10
  import { appAuthOptions, buildAppAuth } from "./better-auth/auth-settings.js";
11
11
  import { buildAuthenticator } from "./better-auth/authenticate-settings.js";
12
12
  import { webhooksModule } from "./webhooks/table/webhooks-module.js";
13
- export { appAuthOptions, appPool, betterAuthAuthenticator, buildAppAuth, buildAuth, buildAuthenticator, checkNotificationsOptions, createAuth, declaredJob, fileProviderOf, isSubscription, loadAuthPlugins, localFiles, longQueryPool, s3Files, webhooksModule };
13
+ import { checkCallsOptions } from "./calls/calls-options.js";
14
+ export { appAuthOptions, appPool, betterAuthAuthenticator, buildAppAuth, buildAuth, buildAuthenticator, checkCallsOptions, checkNotificationsOptions, createAuth, declaredJob, fileProviderOf, isSubscription, loadAuthPlugins, localFiles, longQueryPool, s3Files, webhooksModule };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.58.4";
2
+ var version = "0.58.6";
3
3
  //#endregion
4
4
  export { version };
@@ -54,7 +54,8 @@ const callFunction = async (name, subscription, event, options, context, signal)
54
54
  name,
55
55
  db: context.db,
56
56
  caller: async () => caller,
57
- acceptWebSocket
57
+ acceptWebSocket,
58
+ calls: { join: async () => acceptWebSocket() }
58
59
  };
59
60
  return invokeFunction(name, fn, await request("http://protobase.internal/", event, options, signal), fn.options.public ? functionContext : {
60
61
  ...functionContext,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.58.4",
3
+ "version": "0.58.6",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -40,10 +40,11 @@
40
40
  "@better-auth/oauth-provider": "^1.7.12",
41
41
  "@better-auth/passkey": "^1.7.12",
42
42
  "@better-auth/sso": "^1.7.12",
43
+ "@livekit/protocol": "1.53.1",
43
44
  "@modelcontextprotocol/server": "^2.3.1",
44
- "@protobase/layout": "0.58.4",
45
- "@protobase/query": "0.58.4",
46
- "@protobase/schema": "0.58.4",
45
+ "@protobase/layout": "0.58.6",
46
+ "@protobase/query": "0.58.6",
47
+ "@protobase/schema": "0.58.6",
47
48
  "@react-email/render": "^2.1.0",
48
49
  "@scalar/hono-api-reference": "^0.12.10",
49
50
  "@sentry/core": "^11.7.0",