@protobase/server 0.59.3 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/calls/admission.d.ts +4 -0
  2. package/dist/calls/admission.js +12 -0
  3. package/dist/calls/call-hub.js +170 -0
  4. package/dist/calls/call-member.d.ts +19 -6
  5. package/dist/calls/call-reads.d.ts +29 -0
  6. package/dist/calls/call-reads.js +79 -0
  7. package/dist/calls/call-signals-sql.d.ts +12 -0
  8. package/dist/calls/call-signals-sql.js +19 -0
  9. package/dist/calls/call-store.d.ts +30 -0
  10. package/dist/calls/call-store.js +112 -0
  11. package/dist/calls/call-sweep.js +26 -0
  12. package/dist/calls/call-watch.js +39 -0
  13. package/dist/calls/calls-access.d.ts +20 -5
  14. package/dist/calls/calls-access.js +38 -17
  15. package/dist/calls/calls-options.d.ts +9 -1
  16. package/dist/calls/calls-options.js +10 -2
  17. package/dist/calls/calls-runtime.d.ts +33 -0
  18. package/dist/calls/calls-runtime.js +94 -0
  19. package/dist/calls/calls-service-type.d.ts +11 -0
  20. package/dist/calls/calls-service.js +27 -0
  21. package/dist/calls/calls-sql.d.ts +7 -0
  22. package/dist/calls/calls-sql.js +66 -0
  23. package/dist/calls/calls-store.d.ts +23 -0
  24. package/dist/calls/calls-store.js +20 -0
  25. package/dist/calls/client-message.js +1 -0
  26. package/dist/calls/close-codes.js +17 -0
  27. package/dist/calls/livekit/livekit-session.js +1 -0
  28. package/dist/calls/member-relay.js +69 -17
  29. package/dist/calls/peer/peer-backend.js +68 -120
  30. package/dist/calls/record-calls.js +78 -0
  31. package/dist/create-admin.js +4 -2
  32. package/dist/deps.d.ts +3 -0
  33. package/dist/functions/function-socket.d.ts +1 -0
  34. package/dist/functions/function-socket.js +14 -2
  35. package/dist/functions/routes.js +3 -4
  36. package/dist/index.d.ts +2 -1
  37. package/dist/index.js +2 -1
  38. package/dist/package.js +1 -1
  39. package/dist/routes/record.js +6 -0
  40. package/dist/webhooks/attempt.js +6 -1
  41. package/package.json +4 -4
@@ -1,25 +1,46 @@
1
+ import { forbidden } from "../problem.js";
1
2
  //#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");
3
+ /** The largest message of a member's socket: a description, with room to spare. */
4
+ const maxMessageBytes = 307200;
5
+ const checkMember = ({ record, member }) => {
6
+ if (typeof record?.resource !== "string" || record.resource.length === 0) throw new TypeError("calls.join needs the record, { resource, key }");
4
7
  if (typeof member !== "string" || member.length === 0) throw new TypeError("calls.join needs the member, a non-empty string");
5
8
  };
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
- };
9
+ const withoutCalls = () => Promise.reject(/* @__PURE__ */ new Error("This app has no calls: configure calls: { backend } in protobase.config.ts"));
10
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.
11
+ * Calls as a function sees them, for the caller `caller` gives: a public function's resolves only once it is asked
12
+ * for, and rejects without a valid token. An app without calls loads none of their code.
13
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
- } };
14
+ const callsAccess = (service, caller) => {
15
+ if (!service) return {
16
+ join: withoutCalls,
17
+ remove: withoutCalls,
18
+ of: withoutCalls
19
+ };
20
+ return {
21
+ join: async (member) => {
22
+ checkMember(member);
23
+ const { session, records } = await caller();
24
+ if (session.anonymous || session.user.id === "") throw forbidden("call-needs-user", "Sign in to join a call");
25
+ const { resource, key } = member.record;
26
+ const record = service.recordOf(resource, key);
27
+ const mayRead = () => records.get(resource, key);
28
+ await mayRead();
29
+ return {
30
+ ...await (await service.runtime()).relay(record, member.member, {
31
+ publish: member.publish,
32
+ ...member.hears && { hears: member.hears }
33
+ }),
34
+ maxMessageBytes,
35
+ recheck: async () => {
36
+ await mayRead();
37
+ await member.recheck?.();
38
+ }
39
+ };
40
+ },
41
+ remove: async (call, member) => (await service.runtime()).remove(call, member),
42
+ of: async ({ resource, key }) => (await service.runtime()).of(service.recordOf(resource, key))
43
+ };
23
44
  };
24
45
  //#endregion
25
46
  export { callsAccess };
@@ -16,17 +16,25 @@ export type CallIceServer = {
16
16
  };
17
17
  /**
18
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.
19
+ * answers and candidates between them. For small calls: at most `maxMembers`, 8 by default.
20
20
  */
21
21
  export type PeerBackend = {
22
22
  kind: 'peer';
23
23
  iceServers?: CallIceServer[];
24
+ maxMembers?: number;
24
25
  };
25
26
  /** The media plugin a call's members send and receive their media through. */
26
27
  export type CallBackend = LiveKitBackend | PeerBackend;
27
28
  /** Calls: the media plugin behind them. Without `calls`, an app loads none of the calls code. */
28
29
  export type CallsOptions = {
29
30
  backend: CallBackend;
31
+ /** How long a call lasts at most, in minutes, 240 by default: then it ends, whoever is still in it. */
32
+ maxMinutes?: number;
33
+ /**
34
+ * `'one'`, the default, when one process serves the app. `'many'` when several serve it behind one address: the
35
+ * members' signaling then goes between the processes through the `calls` schema.
36
+ */
37
+ processes?: 'one' | 'many';
30
38
  };
31
39
  /** The options, or an error naming what is wrong with them: the backend's kind and each of its settings. */
32
40
  export declare const checkCallsOptions: (options: CallsOptions) => CallsOptions;
@@ -1,11 +1,19 @@
1
- //#region src/calls/calls-options.ts
1
+ /** The most members a call of these options admits; a LiveKit call has no limit of Protobase's. */
2
+ const maxMembersOf = (backend) => backend.kind === "peer" ? backend.maxMembers ?? 8 : Number.POSITIVE_INFINITY;
2
3
  const nonEmpty = (value) => typeof value === "string" && value.length > 0;
4
+ const positiveInteger = (value) => Number.isInteger(value) && value > 0;
5
+ const checkShared = (options) => {
6
+ if (options.maxMinutes !== void 0 && !positiveInteger(options.maxMinutes)) throw new Error(`calls.maxMinutes must be a whole number of minutes of at least 1, not ${String(options.maxMinutes)}`);
7
+ if (options.processes !== void 0 && options.processes !== "one" && options.processes !== "many") throw new Error(`calls.processes must be 'one' or 'many', not ${JSON.stringify(options.processes)}`);
8
+ };
3
9
  /** The options, or an error naming what is wrong with them: the backend's kind and each of its settings. */
4
10
  const checkCallsOptions = (options) => {
5
11
  const backend = options?.backend;
12
+ if (backend?.kind === "peer" || backend?.kind === "livekit") checkShared(options);
6
13
  if (backend?.kind === "peer") {
7
14
  const servers = backend.iceServers;
8
15
  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? }");
16
+ if (backend.maxMembers !== void 0 && !(positiveInteger(backend.maxMembers) && backend.maxMembers >= 2)) throw new Error(`calls.backend.maxMembers must be a whole number of at least 2, not ${String(backend.maxMembers)}`);
9
17
  return options;
10
18
  }
11
19
  if (backend?.kind !== "livekit") throw new Error(`calls.backend.kind must be 'livekit' or 'peer', not ${JSON.stringify(backend?.kind)}`);
@@ -18,4 +26,4 @@ const checkCallsOptions = (options) => {
18
26
  return options;
19
27
  };
20
28
  //#endregion
21
- export { checkCallsOptions };
29
+ export { checkCallsOptions, maxMembersOf };
@@ -0,0 +1,33 @@
1
+ import { CallsOptions } from "./calls-options.js";
2
+ import { SocketEvents } from "../functions/function-socket.js";
3
+ import { CallRights } from "./call-member.js";
4
+ import { CallRecord } from "./call-store.js";
5
+ import { CallItem } from "./call-reads.js";
6
+ import { CallStore } from "./calls-store.js";
7
+ import { Db } from "@protobase/query";
8
+ //#region src/calls/calls-runtime.d.ts
9
+ export type CallsRuntimeInput = {
10
+ db: Db;
11
+ timeoutMs: number;
12
+ options: CallsOptions;
13
+ report: (error: unknown) => void;
14
+ /** Memory in tests; the `calls` schema otherwise. */
15
+ store?: CallStore;
16
+ };
17
+ /**
18
+ * An app's calls on this process: the members it serves, through the configured plugin, and the records whose calls
19
+ * its pages follow. While it serves a member or a page, it reads them every second and, every five, marks its members
20
+ * as served and sweeps every call, whichever process served it; with neither, it runs no timer.
21
+ */
22
+ export declare const callsRuntime: ({ db, timeoutMs, options, report, store }: CallsRuntimeInput) => {
23
+ /** The events of the member's socket, relayed to the plugin: they come into the record's call at their `join`. */
24
+ relay: (record: CallRecord, member: string, rights: CallRights) => Promise<SocketEvents>;
25
+ /** Removes the member from the call, and only from it: whether they were in it. */
26
+ remove: (call: string, member: string) => Promise<boolean>;
27
+ /** The record's latest calls, newest first, once every call that should have ended has. */
28
+ of: (record: CallRecord) => Promise<CallItem[]>;
29
+ /** Tells `listener` the record's calls now and whenever they change, until the returned function is called. */
30
+ watch: (record: CallRecord, listener: (items: CallItem[]) => void) => Promise<() => undefined>;
31
+ };
32
+ export type CallsRuntime = ReturnType<typeof callsRuntime>;
33
+ //#endregion
@@ -0,0 +1,94 @@
1
+ import { maxMembersOf } from "./calls-options.js";
2
+ import { callHub } from "./call-hub.js";
3
+ import { recordId } from "./call-reads.js";
4
+ import { callWatch } from "./call-watch.js";
5
+ import { sqlCallStore } from "./calls-store.js";
6
+ import { memberRelay } from "./member-relay.js";
7
+ //#region src/calls/calls-runtime.ts
8
+ /** How often the calls this process serves, and the records its pages follow, are read. */
9
+ const readEveryMs = 1e3;
10
+ /** Every how many reads the process tells its members' records it still serves them, and sweeps every call. */
11
+ const beatEvery = 5;
12
+ const loadBackend = async (settings) => {
13
+ if (settings.kind === "livekit") return (await import("./livekit/livekit-session.js")).liveKitBackend(settings);
14
+ return (await import("./peer/peer-backend.js")).peerBackend(settings);
15
+ };
16
+ /**
17
+ * An app's calls on this process: the members it serves, through the configured plugin, and the records whose calls
18
+ * its pages follow. While it serves a member or a page, it reads them every second and, every five, marks its members
19
+ * as served and sweeps every call, whichever process served it; with neither, it runs no timer.
20
+ */
21
+ const callsRuntime = ({ db, timeoutMs, options, report, store = sqlCallStore(db, timeoutMs) }) => {
22
+ const hub = callHub({
23
+ store,
24
+ manyProcesses: options.processes === "many",
25
+ report
26
+ });
27
+ const watch = callWatch(store);
28
+ const maxMembers = maxMembersOf(options.backend);
29
+ const maxMinutes = options.maxMinutes ?? 240;
30
+ let backend;
31
+ let timer;
32
+ let reads = 0;
33
+ const busy = () => hub.size > 0 || watch.size > 0;
34
+ const tick = async () => {
35
+ reads++;
36
+ if (reads % beatEvery === 0) {
37
+ await hub.beat();
38
+ await store.sweep();
39
+ }
40
+ await hub.refreshAll();
41
+ await watch.read();
42
+ };
43
+ const schedule = () => {
44
+ if (timer || !busy()) return;
45
+ timer = setTimeout(() => {
46
+ tick().catch(report).finally(() => {
47
+ timer = void 0;
48
+ schedule();
49
+ });
50
+ }, readEveryMs);
51
+ timer.unref?.();
52
+ };
53
+ const door = { admit: async (input, link) => {
54
+ const admitted = await hub.admit(input, link);
55
+ schedule();
56
+ return admitted;
57
+ } };
58
+ return {
59
+ /** The events of the member's socket, relayed to the plugin: they come into the record's call at their `join`. */
60
+ relay: async (record, member, rights) => {
61
+ backend ??= loadBackend(options.backend);
62
+ return memberRelay({
63
+ door,
64
+ backend: await backend,
65
+ record,
66
+ member,
67
+ rights,
68
+ maxMembers,
69
+ maxMinutes,
70
+ report
71
+ });
72
+ },
73
+ /** Removes the member from the call, and only from it: whether they were in it. */
74
+ remove: async (call, member) => {
75
+ const removed = await store.remove(call, member);
76
+ await hub.refresh([call]);
77
+ return removed;
78
+ },
79
+ /** The record's latest calls, newest first, once every call that should have ended has. */
80
+ of: async (record) => {
81
+ await store.sweep();
82
+ return (await store.recordCalls([record])).get(recordId(record)) ?? [];
83
+ },
84
+ /** Tells `listener` the record's calls now and whenever they change, until the returned function is called. */
85
+ watch: async (record, listener) => {
86
+ await store.sweep();
87
+ const stop = await watch.add(record, listener);
88
+ schedule();
89
+ return stop;
90
+ }
91
+ };
92
+ };
93
+ //#endregion
94
+ export { callsRuntime };
@@ -0,0 +1,11 @@
1
+ import { CallRecordKey } from "./call-member.js";
2
+ import { CallRecord } from "./call-store.js";
3
+ import { CallsRuntime } from "./calls-runtime.js";
4
+ //#region src/calls/calls-service-type.d.ts
5
+ /** An app's calls as its functions and routes reach them: loaded at their first use, and the stored form of a record's key. */
6
+ export type CallsService = {
7
+ runtime: () => Promise<CallsRuntime>;
8
+ /** The resource and encoded key of a record a function names; a 404 problem for an unknown resource. */
9
+ recordOf: (resource: string, key: CallRecordKey) => CallRecord;
10
+ };
11
+ //#endregion
@@ -0,0 +1,27 @@
1
+ import { toolKey } from "../assistant/records.js";
2
+ import { encodeKey } from "@protobase/schema/internal";
3
+ //#region src/calls/calls-service.ts
4
+ /** An app's calls over its resources: the runtime loaded at the first use, so an app that never calls loads none of it. */
5
+ const callsService = (deps, options) => {
6
+ let runtime;
7
+ return {
8
+ runtime: () => runtime ??= import("./calls-runtime.js").then(({ callsRuntime }) => {
9
+ const { db, statementTimeoutMs, report } = deps();
10
+ return callsRuntime({
11
+ db,
12
+ timeoutMs: statementTimeoutMs,
13
+ options,
14
+ report
15
+ });
16
+ }),
17
+ recordOf: (resource, key) => {
18
+ const found = toolKey(deps(), resource, key);
19
+ return {
20
+ resource: found.entry.name,
21
+ key: encodeKey(found.key)
22
+ };
23
+ }
24
+ };
25
+ };
26
+ //#endregion
27
+ export { callsService };
@@ -0,0 +1,7 @@
1
+ import { Db } from "@protobase/query";
2
+ //#region src/calls/calls-sql.d.ts
3
+ /** The SQL that adds the `calls` schema and its tables, `undefined` when they are there. They are never dropped. */
4
+ export declare const callsMigration: (db: Db) => Promise<"create schema if not exists \"calls\";\n\ncreate table if not exists \"calls\".\"calls\" (\n \"id\" uuid primary key,\n \"resource\" text not null,\n \"record_key\" text not null,\n \"started_by\" text not null,\n \"started_at\" timestamptz not null,\n \"ends_at\" timestamptz not null,\n \"ended_at\" timestamptz\n);\ncreate unique index if not exists \"calls_live\" on \"calls\".\"calls\" (\"resource\", \"record_key\") where \"ended_at\" is null;\ncreate index if not exists \"calls_record\" on \"calls\".\"calls\" (\"resource\", \"record_key\", \"started_at\");\n\ncreate table if not exists \"calls\".\"members\" (\n \"id\" bigint generated always as identity primary key,\n \"call_id\" uuid not null references \"calls\".\"calls\" (\"id\") on delete cascade,\n \"member\" text not null,\n \"process\" uuid not null,\n \"tracks\" jsonb not null,\n \"joined_at\" timestamptz not null,\n \"seen_at\" timestamptz not null,\n \"left_at\" timestamptz,\n \"removed_at\" timestamptz\n);\ncreate unique index if not exists \"members_present\" on \"calls\".\"members\" (\"call_id\", \"member\") where \"left_at\" is null;\ncreate index if not exists \"members_call\" on \"calls\".\"members\" (\"call_id\", \"member\");\ncreate index if not exists \"members_seen\" on \"calls\".\"members\" (\"seen_at\") where \"left_at\" is null;\n\ncreate table if not exists \"calls\".\"signals\" (\n \"id\" bigint generated always as identity primary key,\n \"process\" uuid not null,\n \"call_id\" uuid not null,\n \"to_member\" text not null,\n \"from_member\" text not null,\n \"body\" json not null,\n \"at\" timestamptz not null\n);\ncreate index if not exists \"signals_process\" on \"calls\".\"signals\" (\"process\", \"id\");\ncreate index if not exists \"signals_at\" on \"calls\".\"signals\" (\"at\");;" | undefined>;
5
+ /** Refuses to go on while the app has `calls` and the database lacks their tables; the project's migrations add them. */
6
+ export declare const checkCalls: (db: Db, calls: unknown) => Promise<void>;
7
+ //#endregion
@@ -0,0 +1,66 @@
1
+ import { migrationCommand } from "../changes/capture-schema.js";
2
+ import { sql } from "kysely";
3
+ //#region src/calls/calls-sql.ts
4
+ /**
5
+ * - `calls`: a call on a record, by resource and encoded key: who started it, when, the latest it may end, and when it
6
+ * ended (NULL while it goes on). A record has at most one call going on. Its id is random, so it is unique beyond
7
+ * the app too, as the room of a media server other apps share.
8
+ * - `members`: a member's time in a call, from their join until they left or were removed; the process that serves
9
+ * them and when it last heard of them. A removal of someone not in the call is a row that starts and ends at once.
10
+ * - `signals`: with several processes, a member's description or candidate for a member another process serves,
11
+ * taken out by that process as it reads it, and deleted after a minute when no process did.
12
+ */
13
+ const callsSchemaSql = `create schema if not exists "calls";
14
+
15
+ create table if not exists "calls"."calls" (
16
+ "id" uuid primary key,
17
+ "resource" text not null,
18
+ "record_key" text not null,
19
+ "started_by" text not null,
20
+ "started_at" timestamptz not null,
21
+ "ends_at" timestamptz not null,
22
+ "ended_at" timestamptz
23
+ );
24
+ create unique index if not exists "calls_live" on "calls"."calls" ("resource", "record_key") where "ended_at" is null;
25
+ create index if not exists "calls_record" on "calls"."calls" ("resource", "record_key", "started_at");
26
+
27
+ create table if not exists "calls"."members" (
28
+ "id" bigint generated always as identity primary key,
29
+ "call_id" uuid not null references "calls"."calls" ("id") on delete cascade,
30
+ "member" text not null,
31
+ "process" uuid not null,
32
+ "tracks" jsonb not null,
33
+ "joined_at" timestamptz not null,
34
+ "seen_at" timestamptz not null,
35
+ "left_at" timestamptz,
36
+ "removed_at" timestamptz
37
+ );
38
+ create unique index if not exists "members_present" on "calls"."members" ("call_id", "member") where "left_at" is null;
39
+ create index if not exists "members_call" on "calls"."members" ("call_id", "member");
40
+ create index if not exists "members_seen" on "calls"."members" ("seen_at") where "left_at" is null;
41
+
42
+ create table if not exists "calls"."signals" (
43
+ "id" bigint generated always as identity primary key,
44
+ "process" uuid not null,
45
+ "call_id" uuid not null,
46
+ "to_member" text not null,
47
+ "from_member" text not null,
48
+ "body" json not null,
49
+ "at" timestamptz not null
50
+ );
51
+ create index if not exists "signals_process" on "calls"."signals" ("process", "id");
52
+ create index if not exists "signals_at" on "calls"."signals" ("at");`;
53
+ const hasCallsTables = async (db) => (await sql`select to_regclass('calls.calls') is not null and to_regclass('calls.members') is not null and to_regclass('calls.signals') is not null as found`.execute(db)).rows[0].found;
54
+ /** The SQL that adds the `calls` schema and its tables, `undefined` when they are there. They are never dropped. */
55
+ const callsMigration = async (db) => {
56
+ if (await hasCallsTables(db)) return void 0;
57
+ return `${callsSchemaSql};`;
58
+ };
59
+ /** Refuses to go on while the app has `calls` and the database lacks their tables; the project's migrations add them. */
60
+ const checkCalls = async (db, calls) => {
61
+ if (!calls) return;
62
+ if (await hasCallsTables(db)) return;
63
+ throw new Error(`The calls schema is missing, and calls keep their records in it. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
64
+ };
65
+ //#endregion
66
+ export { callsMigration, callsSchemaSql, checkCalls };
@@ -0,0 +1,23 @@
1
+ import { Admission, AdmitInput, CallRecord } from "./call-store.js";
2
+ import { CallItem, RosterMember } from "./call-reads.js";
3
+ import { StoredSignal } from "./call-signals-sql.js";
4
+ import { CallRemoteTrack } from "@protobase/schema/internal";
5
+ import { Db } from "@protobase/query";
6
+ //#region src/calls/calls-store.d.ts
7
+ /** Where calls, their members and the signals between processes are kept: the `calls` schema, or memory in tests. */
8
+ export type CallStore = {
9
+ admit: (input: AdmitInput) => Promise<Admission>;
10
+ leave: (row: string, process: string) => Promise<void>;
11
+ remove: (call: string, member: string) => Promise<boolean>;
12
+ setTracks: (row: string, process: string, tracks: CallRemoteTrack[]) => Promise<void>;
13
+ heartbeat: (process: string, rows: readonly string[]) => Promise<void>;
14
+ sweep: () => Promise<void>;
15
+ rosters: (calls: readonly string[]) => Promise<Map<string, {
16
+ ended: boolean;
17
+ members: RosterMember[];
18
+ }>>;
19
+ recordCalls: (records: readonly CallRecord[]) => Promise<Map<string, CallItem[]>>;
20
+ writeSignal: (signal: Omit<StoredSignal, 'id'>) => Promise<void>;
21
+ takeSignals: (process: string, limit: number) => Promise<StoredSignal[]>;
22
+ };
23
+ //#endregion
@@ -0,0 +1,20 @@
1
+ import { recordCalls, rosters } from "./call-reads.js";
2
+ import { takeSignals, writeSignal } from "./call-signals-sql.js";
3
+ import { admit, heartbeat, leave, remove, setTracks } from "./call-store.js";
4
+ import { sweepCalls } from "./call-sweep.js";
5
+ //#region src/calls/calls-store.ts
6
+ /** The `calls` schema of the app's database; writes under the API's statement timeout. */
7
+ const sqlCallStore = (db, timeoutMs) => ({
8
+ admit: (input) => admit(db, timeoutMs, input),
9
+ leave: (row, process) => leave(db, timeoutMs, row, process),
10
+ remove: (call, member) => remove(db, timeoutMs, call, member),
11
+ setTracks: (row, process, tracks) => setTracks(db, row, process, tracks),
12
+ heartbeat: (process, rows) => heartbeat(db, process, rows),
13
+ sweep: () => sweepCalls(db, timeoutMs),
14
+ rosters: (calls) => rosters(db, calls),
15
+ recordCalls: (records) => recordCalls(db, records),
16
+ writeSignal: (signal) => writeSignal(db, signal),
17
+ takeSignals: (process, limit) => takeSignals(db, process, limit)
18
+ });
19
+ //#endregion
20
+ export { sqlCallStore };
@@ -42,6 +42,7 @@ const message = z.discriminatedUnion("type", [
42
42
  z.strictObject({ type: z.literal("join") }),
43
43
  z.strictObject({
44
44
  type: z.literal("resume"),
45
+ call: id,
45
46
  participant: id,
46
47
  offer: sdp.optional(),
47
48
  answer: sdp.optional(),
@@ -0,0 +1,17 @@
1
+ import { resumeCloseCode } from "./media-backend.js";
2
+ //#region src/calls/close-codes.ts
3
+ const lostCodes = /* @__PURE__ */ new Set([
4
+ 1001,
5
+ 1005,
6
+ 1006,
7
+ 1012,
8
+ resumeCloseCode
9
+ ]);
10
+ /**
11
+ * How a member's socket ended, by its close code: lost, so the browser may resume within the grace, or left. Every
12
+ * other code is left, the server's 1008 and 1011 among them, so a check that refused the caller or could not run ends
13
+ * their time in the call at once.
14
+ */
15
+ const memberEnd = (code) => lostCodes.has(code) ? "lost" : "left";
16
+ //#endregion
17
+ export { memberEnd };
@@ -116,6 +116,7 @@ const liveKitSession = (backend, member, link, deps) => {
116
116
  signal.keepAlive(join.pingInterval || resumePing.interval, join.pingTimeout || resumePing.timeout);
117
117
  toBrowser({
118
118
  type: "joined",
119
+ call: member.call,
119
120
  mode: "sfu",
120
121
  participant: join.participant.sid,
121
122
  iceServers: join.iceServers.map((server) => ({
@@ -1,35 +1,87 @@
1
+ import { memberEnd } from "./close-codes.js";
1
2
  import { parseClientMessage } from "./client-message.js";
2
3
  //#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
+ /** The close code of a socket whose caller Protobase refused: the member leaves the call too. */
4
5
  const refusedCloseCode = 1008;
5
- const mayPublish = (member, tracks) => member.publish === true || Array.isArray(member.publish) && tracks.every((track) => member.publish.includes(track.source));
6
+ const mayPublish = (rights, tracks) => rights.publish === true || Array.isArray(rights.publish) && tracks.every((track) => rights.publish.includes(track.source));
7
+ const refusals = {
8
+ removed: "You were removed from this call",
9
+ full: "This call is full",
10
+ ended: "This call has ended"
11
+ };
6
12
  /**
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.
13
+ * One member's socket, relayed to the call's media plugin: the relay reads and checks the browser's messages, lets
14
+ * the member into the record's call at their `join` or `resume`, or tells them why not, refuses a publish the member
15
+ * may not make, and tells the call and the plugin how the socket ended. The plugin only ever sees well-formed messages
16
+ * of a member Protobase let in.
10
17
  */
11
- const memberRelay = (backend, member) => {
18
+ const memberRelay = ({ door, backend, record, member, rights, maxMembers, maxMinutes, report }) => {
12
19
  let browser;
13
20
  let session;
21
+ let inCall;
14
22
  let finished = false;
15
- const send = (message) => void browser.send(JSON.stringify(message));
23
+ let rejoining = false;
24
+ const send = (message) => {
25
+ if (message.type === "left" && message.reason === "rejoin") rejoining = true;
26
+ browser.send(JSON.stringify(message));
27
+ };
28
+ const end = (how) => {
29
+ session?.end(how);
30
+ if (!inCall) return;
31
+ if (how === "left") return void inCall.leave().catch(report);
32
+ inCall.detach();
33
+ };
16
34
  const close = (code, reason) => {
17
35
  if (finished) return;
18
36
  finished = true;
19
- session?.end(code === 1e3 || code === refusedCloseCode ? "left" : "lost");
37
+ end(rejoining ? "lost" : memberEnd(code));
20
38
  browser.close(code, reason);
21
39
  };
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
40
+ const enter = async (message) => {
41
+ if (session) return close(refusedCloseCode, "The call session already started");
42
+ const resume = message.type === "resume" ? {
43
+ call: message.call,
44
+ tracks: message.tracks.map((track) => ({
45
+ sid: track.sid,
46
+ member,
47
+ kind: track.kind,
48
+ source: track.source,
49
+ muted: false
50
+ }))
51
+ } : void 0;
52
+ const admitted = await door.admit({
53
+ record,
54
+ member,
55
+ maxMembers,
56
+ maxMinutes,
57
+ ...resume && { resume }
58
+ }, {
59
+ send,
60
+ close
61
+ });
62
+ if ("refused" in admitted) {
63
+ send({
64
+ type: "left",
65
+ reason: admitted.refused
28
66
  });
29
- return session.receive(message);
67
+ return close(refusedCloseCode, refusals[admitted.refused]);
30
68
  }
69
+ inCall = admitted;
70
+ session = backend.open({
71
+ ...rights,
72
+ call: admitted.call,
73
+ member
74
+ }, {
75
+ send,
76
+ close
77
+ }, admitted.room);
78
+ await session.receive(message);
79
+ await admitted.listen(session.roster ?? (() => {}));
80
+ };
81
+ const relay = async (message) => {
82
+ if (message.type === "join" || message.type === "resume") return enter(message);
31
83
  if (!session) return close(refusedCloseCode, "Join or resume the call first");
32
- if (message.type === "publish" && !mayPublish(member, message.tracks)) return send({
84
+ if (message.type === "publish" && !mayPublish(rights, message.tracks)) return send({
33
85
  type: "publish-refused",
34
86
  reason: "The member may not publish these tracks"
35
87
  });
@@ -48,7 +100,7 @@ const memberRelay = (backend, member) => {
48
100
  onClose: (code) => {
49
101
  if (finished) return;
50
102
  finished = true;
51
- session?.end(code === refusedCloseCode ? "left" : "lost");
103
+ end(rejoining ? "lost" : memberEnd(code));
52
104
  }
53
105
  };
54
106
  };