@takosjp/yurucommu-core 3.2.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0020_call_sessions.sql +23 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/rtc-client.ts +542 -0
- package/packages/api/src/types/call.ts +306 -0
- package/packages/api/src/types/index.ts +3 -0
- package/src/backend/index.ts +21 -1
- package/src/backend/lib/rtc/call-store.ts +101 -0
- package/src/backend/lib/rtc/provider.ts +135 -0
- package/src/backend/lib/rtc/signal-transport.ts +125 -0
- package/src/backend/public.ts +3 -0
- package/src/backend/routes/activitypub.ts +5 -1
- package/src/backend/routes/auth.ts +8 -2
- package/src/backend/routes/rtc/index.ts +147 -0
- package/src/backend/runtime/call-hub-core.ts +470 -0
- package/src/backend/runtime/call-hub-port.ts +78 -0
- package/src/backend/runtime/call-signaling-do.ts +242 -0
- package/src/backend/runtime/signaling-hub.ts +187 -0
- package/src/backend/types.ts +28 -0
- package/src/db/schema/calls.ts +46 -0
- package/src/db/schema/index.ts +1 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call signaling wire contract (voice + video).
|
|
3
|
+
*
|
|
4
|
+
* SINGLE SOURCE OF TRUTH shared by:
|
|
5
|
+
* - the backend cross-instance signaling ingest (`/ap/rtc/signal`) + the
|
|
6
|
+
* Signaling Durable Object (server-to-server + browser fan-out), and
|
|
7
|
+
* - the browser `CallClient` (`../lib/rtc-client.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Kept deliberately DOM-structural (no `RTCIceCandidateInit` / `RTCIceServer`
|
|
10
|
+
* imports) so the same file type-checks in the server context (which never runs
|
|
11
|
+
* the browser WebRTC APIs) and the browser bundle. The `CallClient` maps these
|
|
12
|
+
* structural shapes to/from the real DOM `RTCSessionDescriptionInit` /
|
|
13
|
+
* `RTCIceCandidateInit` / `RTCIceServer`, which are structurally compatible.
|
|
14
|
+
*
|
|
15
|
+
* Design: signaling travels over federation (server-to-server, HTTP-Signature
|
|
16
|
+
* authenticated) as `RtcSignalEnvelopeV1`; media is P2P WebRTC + STUN/TURN for
|
|
17
|
+
* 1:1 (`sfuFocus: null`) and a pluggable WHIP/WHEP SFU focus for group calls.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const RTC_SIGNAL_ENVELOPE_VERSION = 1 as const;
|
|
21
|
+
|
|
22
|
+
/** Which media tracks a call carries. `video:false` => audio-only call. */
|
|
23
|
+
export interface CallMediaKind {
|
|
24
|
+
audio: boolean;
|
|
25
|
+
video: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Cross-instance signaling message kinds (Matrix-VoIP inspired). */
|
|
29
|
+
export type RtcSignalType =
|
|
30
|
+
| "offer"
|
|
31
|
+
| "answer"
|
|
32
|
+
| "candidate"
|
|
33
|
+
| "accept"
|
|
34
|
+
| "reject"
|
|
35
|
+
| "hangup"
|
|
36
|
+
| "cancel";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Selected SFU focus for a group call. `null`/absent means pure P2P (1:1).
|
|
40
|
+
* `kind` names the adapter (`whip` / `livekit` / `cloudflare-realtime` / ...);
|
|
41
|
+
* the client talks WHIP/WHEP so the SFU backend stays vendor-neutral.
|
|
42
|
+
*/
|
|
43
|
+
export interface SfuFocus {
|
|
44
|
+
kind: string;
|
|
45
|
+
/** WHIP (publish) / WHEP (subscribe) endpoint base, or SFU signaling URL. */
|
|
46
|
+
url: string;
|
|
47
|
+
/** Short-lived join token when the adapter requires one. */
|
|
48
|
+
token?: string;
|
|
49
|
+
room?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Structural mirror of `RTCIceCandidateInit` (no DOM dependency). */
|
|
53
|
+
export interface CallIceCandidate {
|
|
54
|
+
candidate: string;
|
|
55
|
+
sdpMid?: string | null;
|
|
56
|
+
sdpMLineIndex?: number | null;
|
|
57
|
+
usernameFragment?: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Structural mirror of `RTCIceServer` (no DOM dependency). */
|
|
61
|
+
export interface IceServerConfig {
|
|
62
|
+
urls: string | string[];
|
|
63
|
+
username?: string;
|
|
64
|
+
credential?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Server-to-server signaling envelope. Delivered by the sending instance to the
|
|
69
|
+
* recipient instance's `/ap/rtc/signal` endpoint, signed with the sender actor's
|
|
70
|
+
* HTTP Signature key (keyId-owner === `from`). `callId` doubles as the anti-
|
|
71
|
+
* replay nonce; `ts`/`ttlMs` bound its freshness (the DO drops stale frames).
|
|
72
|
+
*/
|
|
73
|
+
export interface RtcSignalEnvelopeV1 {
|
|
74
|
+
v: typeof RTC_SIGNAL_ENVELOPE_VERSION;
|
|
75
|
+
callId: string;
|
|
76
|
+
from: string;
|
|
77
|
+
to: string;
|
|
78
|
+
type: RtcSignalType;
|
|
79
|
+
media?: CallMediaKind;
|
|
80
|
+
/** SDP for `offer` / `answer`. */
|
|
81
|
+
sdp?: string;
|
|
82
|
+
/** Half-trickle ICE bundle for `offer` / `answer` / `candidate`. */
|
|
83
|
+
candidates?: CallIceCandidate[];
|
|
84
|
+
sfuFocus?: SfuFocus | null;
|
|
85
|
+
/** Free-text end/reject reason (`busy`, `declined`, `timeout`, ...). */
|
|
86
|
+
reason?: string;
|
|
87
|
+
ts: number;
|
|
88
|
+
ttlMs: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Lifecycle of a single call, mirrored client-side and in `call_sessions`. */
|
|
92
|
+
export type CallState =
|
|
93
|
+
| "idle"
|
|
94
|
+
| "ringing"
|
|
95
|
+
| "connecting"
|
|
96
|
+
| "connected"
|
|
97
|
+
| "ended"
|
|
98
|
+
| "rejected"
|
|
99
|
+
| "missed"
|
|
100
|
+
| "failed"
|
|
101
|
+
| "cancelled";
|
|
102
|
+
|
|
103
|
+
export type CallDirection = "incoming" | "outgoing";
|
|
104
|
+
|
|
105
|
+
/** Terminal states — a call in one of these is over and not resumable. */
|
|
106
|
+
export const TERMINAL_CALL_STATES: readonly CallState[] = [
|
|
107
|
+
"ended",
|
|
108
|
+
"rejected",
|
|
109
|
+
"missed",
|
|
110
|
+
"failed",
|
|
111
|
+
"cancelled",
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
export function isTerminalCallState(state: CallState): boolean {
|
|
115
|
+
return TERMINAL_CALL_STATES.includes(state);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Browser <-> Signaling Durable Object WebSocket frames
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/** Frames the browser sends up to its own instance's Signaling DO. */
|
|
123
|
+
export type ClientToHubFrame =
|
|
124
|
+
| { t: "hello" }
|
|
125
|
+
| { t: "invite"; callId: string; to: string; media: CallMediaKind }
|
|
126
|
+
| { t: "offer"; callId: string; sdp: string }
|
|
127
|
+
| { t: "answer"; callId: string; sdp: string }
|
|
128
|
+
| { t: "candidates"; callId: string; candidates: CallIceCandidate[] }
|
|
129
|
+
| { t: "accept"; callId: string }
|
|
130
|
+
| { t: "reject"; callId: string; reason?: string }
|
|
131
|
+
| { t: "hangup"; callId: string; reason?: string }
|
|
132
|
+
| { t: "resume"; callId: string }
|
|
133
|
+
| { t: "ping" };
|
|
134
|
+
|
|
135
|
+
/** Frames the Signaling DO pushes down to the browser. */
|
|
136
|
+
export type HubToClientFrame =
|
|
137
|
+
| { t: "ready" }
|
|
138
|
+
| { t: "ringing"; callId: string; from: string; media: CallMediaKind }
|
|
139
|
+
| { t: "offer"; callId: string; sdp: string; media?: CallMediaKind }
|
|
140
|
+
| { t: "answer"; callId: string; sdp: string }
|
|
141
|
+
| { t: "candidates"; callId: string; candidates: CallIceCandidate[] }
|
|
142
|
+
| { t: "peer-accepted"; callId: string }
|
|
143
|
+
| { t: "peer-rejected"; callId: string; reason?: string }
|
|
144
|
+
| { t: "peer-hangup"; callId: string; reason?: string }
|
|
145
|
+
| {
|
|
146
|
+
t: "ice-servers";
|
|
147
|
+
callId: string;
|
|
148
|
+
iceServers: IceServerConfig[];
|
|
149
|
+
sfuFocus?: SfuFocus | null;
|
|
150
|
+
}
|
|
151
|
+
| { t: "call-state"; callId: string; state: CallState }
|
|
152
|
+
| { t: "pong" }
|
|
153
|
+
| { t: "error"; code: string; message?: string };
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// REST contract (start call / mint ICE / call history)
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export interface StartCallRequest {
|
|
160
|
+
to: string;
|
|
161
|
+
media: CallMediaKind;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface StartCallResponse {
|
|
165
|
+
callId: string;
|
|
166
|
+
iceServers: IceServerConfig[];
|
|
167
|
+
sfuFocus?: SfuFocus | null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface IceServersResponse {
|
|
171
|
+
iceServers: IceServerConfig[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface CallSessionSummary {
|
|
175
|
+
id: string;
|
|
176
|
+
peer: string;
|
|
177
|
+
direction: CallDirection;
|
|
178
|
+
state: CallState;
|
|
179
|
+
media: CallMediaKind;
|
|
180
|
+
createdAt: string;
|
|
181
|
+
connectedAt?: string | null;
|
|
182
|
+
endedAt?: string | null;
|
|
183
|
+
endReason?: string | null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Runtime validation (used by the backend ingest to reject malformed frames)
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
const SIGNAL_TYPES: readonly RtcSignalType[] = [
|
|
191
|
+
"offer",
|
|
192
|
+
"answer",
|
|
193
|
+
"candidate",
|
|
194
|
+
"accept",
|
|
195
|
+
"reject",
|
|
196
|
+
"hangup",
|
|
197
|
+
"cancel",
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
201
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isCallMediaKind(value: unknown): value is CallMediaKind {
|
|
205
|
+
return (
|
|
206
|
+
isPlainObject(value) &&
|
|
207
|
+
typeof value.audio === "boolean" &&
|
|
208
|
+
typeof value.video === "boolean"
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseCandidates(value: unknown): CallIceCandidate[] | undefined {
|
|
213
|
+
if (value === undefined) return undefined;
|
|
214
|
+
if (!Array.isArray(value)) return undefined;
|
|
215
|
+
const out: CallIceCandidate[] = [];
|
|
216
|
+
for (const raw of value) {
|
|
217
|
+
if (!isPlainObject(raw) || typeof raw.candidate !== "string") continue;
|
|
218
|
+
out.push({
|
|
219
|
+
candidate: raw.candidate,
|
|
220
|
+
sdpMid: typeof raw.sdpMid === "string" ? raw.sdpMid : null,
|
|
221
|
+
sdpMLineIndex:
|
|
222
|
+
typeof raw.sdpMLineIndex === "number" ? raw.sdpMLineIndex : null,
|
|
223
|
+
usernameFragment:
|
|
224
|
+
typeof raw.usernameFragment === "string" ? raw.usernameFragment : null,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseSfuFocus(value: unknown): SfuFocus | null | undefined {
|
|
231
|
+
if (value === undefined) return undefined;
|
|
232
|
+
if (value === null) return null;
|
|
233
|
+
if (!isPlainObject(value)) return undefined;
|
|
234
|
+
if (typeof value.kind !== "string" || typeof value.url !== "string") {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
kind: value.kind,
|
|
239
|
+
url: value.url,
|
|
240
|
+
token: typeof value.token === "string" ? value.token : undefined,
|
|
241
|
+
room: typeof value.room === "string" ? value.room : undefined,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Parse + validate an inbound cross-instance signaling envelope. Returns the
|
|
247
|
+
* normalized envelope or `null` when the shape is invalid. Callers additionally
|
|
248
|
+
* enforce that the HTTP-Signature signer equals `from` and that the recipient
|
|
249
|
+
* (`to`) is a local actor.
|
|
250
|
+
*/
|
|
251
|
+
export function parseRtcSignalEnvelope(
|
|
252
|
+
input: unknown,
|
|
253
|
+
): RtcSignalEnvelopeV1 | null {
|
|
254
|
+
if (!isPlainObject(input)) return null;
|
|
255
|
+
if (input.v !== RTC_SIGNAL_ENVELOPE_VERSION) return null;
|
|
256
|
+
const { callId, from, to, type, ts, ttlMs } = input;
|
|
257
|
+
if (
|
|
258
|
+
typeof callId !== "string" ||
|
|
259
|
+
callId.length === 0 ||
|
|
260
|
+
callId.length > 200 ||
|
|
261
|
+
typeof from !== "string" ||
|
|
262
|
+
from.length === 0 ||
|
|
263
|
+
typeof to !== "string" ||
|
|
264
|
+
to.length === 0 ||
|
|
265
|
+
typeof type !== "string" ||
|
|
266
|
+
!SIGNAL_TYPES.includes(type as RtcSignalType) ||
|
|
267
|
+
typeof ts !== "number" ||
|
|
268
|
+
!Number.isFinite(ts) ||
|
|
269
|
+
typeof ttlMs !== "number" ||
|
|
270
|
+
!Number.isFinite(ttlMs) ||
|
|
271
|
+
ttlMs < 0
|
|
272
|
+
) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const sdp = typeof input.sdp === "string" ? input.sdp : undefined;
|
|
276
|
+
// Guard against absurd SDP blobs abusing the endpoint as a relay.
|
|
277
|
+
if (sdp !== undefined && sdp.length > 100_000) return null;
|
|
278
|
+
return {
|
|
279
|
+
v: RTC_SIGNAL_ENVELOPE_VERSION,
|
|
280
|
+
callId,
|
|
281
|
+
from,
|
|
282
|
+
to,
|
|
283
|
+
type: type as RtcSignalType,
|
|
284
|
+
media: isCallMediaKind(input.media) ? input.media : undefined,
|
|
285
|
+
sdp,
|
|
286
|
+
candidates: parseCandidates(input.candidates),
|
|
287
|
+
sfuFocus: parseSfuFocus(input.sfuFocus),
|
|
288
|
+
reason:
|
|
289
|
+
typeof input.reason === "string"
|
|
290
|
+
? input.reason.slice(0, 200)
|
|
291
|
+
: undefined,
|
|
292
|
+
ts,
|
|
293
|
+
ttlMs,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** True when the envelope is still within its freshness window. */
|
|
298
|
+
export function isEnvelopeFresh(
|
|
299
|
+
envelope: RtcSignalEnvelopeV1,
|
|
300
|
+
now: number,
|
|
301
|
+
): boolean {
|
|
302
|
+
// Reject frames from the future (clock skew tolerance) or past their TTL.
|
|
303
|
+
const skewToleranceMs = 30_000;
|
|
304
|
+
if (envelope.ts - now > skewToleranceMs) return false;
|
|
305
|
+
return now - envelope.ts <= envelope.ttlMs;
|
|
306
|
+
}
|
package/src/backend/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { moderationRoutes } from "./routes/moderation.ts";
|
|
|
29
29
|
import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
|
|
30
30
|
import mobileRoutes from "./routes/mobile.ts";
|
|
31
31
|
import notificationPusherRoutes from "./routes/notification-pushers.ts";
|
|
32
|
+
import rtcRoutes from "./routes/rtc/index.ts";
|
|
32
33
|
|
|
33
34
|
import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
|
|
34
35
|
import { csrfProtection } from "./middleware/csrf.ts";
|
|
@@ -254,6 +255,7 @@ function buildSocialServerDiscovery(
|
|
|
254
255
|
authProviders: `${appUrl}/api/auth/providers`,
|
|
255
256
|
mobilePasswordLogin: `${appUrl}/api/auth/mobile/login`,
|
|
256
257
|
mobileOidcExchange: `${appUrl}/api/auth/mobile/oidc`,
|
|
258
|
+
mobileLogout: `${appUrl}/api/auth/logout`,
|
|
257
259
|
currentUser: `${appUrl}/api/auth/me`,
|
|
258
260
|
timeline: `${appUrl}/api/timeline`,
|
|
259
261
|
conversations: `${appUrl}/api/dm/contacts`,
|
|
@@ -506,6 +508,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
506
508
|
app.use("*", async (c, next) => {
|
|
507
509
|
await next();
|
|
508
510
|
|
|
511
|
+
// A 101 Switching Protocols response carries a WebSocket and immutable
|
|
512
|
+
// headers (the /api/rtc/socket call upgrade). Mutating it throws and breaks
|
|
513
|
+
// the upgrade, so skip the security-header pass for it.
|
|
514
|
+
if (c.res.status === 101) return;
|
|
515
|
+
|
|
509
516
|
const preserveRouteSecurityHeaders = c.req.path.startsWith("/hosted/");
|
|
510
517
|
const setSecurityHeader = (name: string, value: string) => {
|
|
511
518
|
if (preserveRouteSecurityHeaders && c.res.headers.has(name)) {
|
|
@@ -553,9 +560,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
553
560
|
setSecurityHeader("X-Content-Type-Options", "nosniff");
|
|
554
561
|
setSecurityHeader("X-Frame-Options", "DENY");
|
|
555
562
|
setSecurityHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
563
|
+
// Allow the app's OWN origin to use camera + microphone (WebRTC calls);
|
|
564
|
+
// still deny geolocation and deny camera/mic to any cross-origin frame.
|
|
556
565
|
setSecurityHeader(
|
|
557
566
|
"Permissions-Policy",
|
|
558
|
-
"camera=(), microphone=(), geolocation=()",
|
|
567
|
+
"camera=(self), microphone=(self), geolocation=()",
|
|
559
568
|
);
|
|
560
569
|
// HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
|
|
561
570
|
// (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
|
|
@@ -693,6 +702,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
693
702
|
app.use(pattern, rateLimit(RateLimitConfigs.inbox));
|
|
694
703
|
}
|
|
695
704
|
|
|
705
|
+
// Cross-instance call signaling ingest is unauthenticated at the network edge
|
|
706
|
+
// (it verifies an HTTP Signature inside the handler) and can be hit by any
|
|
707
|
+
// remote instance, so throttle it per-IP like the other federation endpoints.
|
|
708
|
+
app.use("/ap/rtc/signal", rateLimit(RateLimitConfigs.federationDiscovery));
|
|
709
|
+
|
|
696
710
|
// Federation discovery endpoints are unauthenticated and can be probed by
|
|
697
711
|
// any remote actor. Throttle them per-IP to mitigate enumeration / DoS.
|
|
698
712
|
app.use(
|
|
@@ -766,6 +780,8 @@ function mountCoreRoutes(app: YurucommuApp): void {
|
|
|
766
780
|
app.route("/api/moderation", moderationRoutes);
|
|
767
781
|
app.route("/api/apps", appsApiRoutes);
|
|
768
782
|
app.route("/hosted", appsServeRoutes);
|
|
783
|
+
// Call feature: /api/rtc/* (session) + /ap/rtc/signal (server-to-server).
|
|
784
|
+
app.route("/", rtcRoutes);
|
|
769
785
|
app.route("/", activitypubRoutes);
|
|
770
786
|
}
|
|
771
787
|
|
|
@@ -933,6 +949,10 @@ type WorkerBindings = EnvVars & {
|
|
|
933
949
|
ASSETS?: Fetcher;
|
|
934
950
|
DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
|
|
935
951
|
DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
|
|
952
|
+
// Signaling hub Durable Object namespace (call feature). wrapCloudflareBindings
|
|
953
|
+
// spreads it through untouched (it is not DB/MEDIA/KV/ASSETS) so app code and
|
|
954
|
+
// the rtc routes read it as c.env.CALL_SIGNALING.
|
|
955
|
+
CALL_SIGNALING?: DurableObjectNamespace;
|
|
936
956
|
};
|
|
937
957
|
|
|
938
958
|
export default {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable persistence for call sessions (history / missed-call / current state).
|
|
3
|
+
* Ephemeral SDP/ICE never touches this — only the call lifecycle does.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
7
|
+
import type { Database } from "../../../db/index.ts";
|
|
8
|
+
import { callSessions, nowIso } from "../../../db/index.ts";
|
|
9
|
+
import type {
|
|
10
|
+
CallSessionSummary,
|
|
11
|
+
CallDirection,
|
|
12
|
+
CallState,
|
|
13
|
+
} from "../../../../packages/api/src/types/call.ts";
|
|
14
|
+
import { isTerminalCallState } from "../../../../packages/api/src/types/call.ts";
|
|
15
|
+
import type { CallRecord } from "../../runtime/call-hub-core.ts";
|
|
16
|
+
|
|
17
|
+
/** Insert or update the durable row for a call transition. */
|
|
18
|
+
export async function upsertCallSession(
|
|
19
|
+
db: Database,
|
|
20
|
+
localActorApId: string,
|
|
21
|
+
call: CallRecord,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const now = nowIso();
|
|
24
|
+
const terminal = isTerminalCallState(call.state);
|
|
25
|
+
const connectedAt = call.connectedAt
|
|
26
|
+
? new Date(call.connectedAt).toISOString()
|
|
27
|
+
: null;
|
|
28
|
+
const sfuFocus = call.sfuFocus ? JSON.stringify(call.sfuFocus) : null;
|
|
29
|
+
await db
|
|
30
|
+
.insert(callSessions)
|
|
31
|
+
.values({
|
|
32
|
+
id: call.callId,
|
|
33
|
+
localActorApId,
|
|
34
|
+
peerActorApId: call.peerApId,
|
|
35
|
+
direction: call.direction,
|
|
36
|
+
state: call.state,
|
|
37
|
+
mediaAudio: call.media.audio ? 1 : 0,
|
|
38
|
+
mediaVideo: call.media.video ? 1 : 0,
|
|
39
|
+
sfuFocus,
|
|
40
|
+
peerSignalEndpoint: call.peerSignalEndpoint ?? null,
|
|
41
|
+
connectedAt,
|
|
42
|
+
endedAt: terminal ? now : null,
|
|
43
|
+
})
|
|
44
|
+
.onConflictDoUpdate({
|
|
45
|
+
target: callSessions.id,
|
|
46
|
+
set: {
|
|
47
|
+
state: call.state,
|
|
48
|
+
sfuFocus,
|
|
49
|
+
peerSignalEndpoint: call.peerSignalEndpoint ?? null,
|
|
50
|
+
connectedAt,
|
|
51
|
+
endedAt: terminal ? now : null,
|
|
52
|
+
updatedAt: now,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toSummary(row: typeof callSessions.$inferSelect): CallSessionSummary {
|
|
58
|
+
return {
|
|
59
|
+
id: row.id,
|
|
60
|
+
peer: row.peerActorApId,
|
|
61
|
+
direction: row.direction as CallDirection,
|
|
62
|
+
state: row.state as CallState,
|
|
63
|
+
media: { audio: row.mediaAudio === 1, video: row.mediaVideo === 1 },
|
|
64
|
+
createdAt: row.createdAt,
|
|
65
|
+
connectedAt: row.connectedAt,
|
|
66
|
+
endedAt: row.endedAt,
|
|
67
|
+
endReason: row.endReason,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function listCallSessions(
|
|
72
|
+
db: Database,
|
|
73
|
+
localActorApId: string,
|
|
74
|
+
limit = 50,
|
|
75
|
+
): Promise<CallSessionSummary[]> {
|
|
76
|
+
const rows = await db
|
|
77
|
+
.select()
|
|
78
|
+
.from(callSessions)
|
|
79
|
+
.where(eq(callSessions.localActorApId, localActorApId))
|
|
80
|
+
.orderBy(desc(callSessions.createdAt))
|
|
81
|
+
.limit(Math.min(Math.max(limit, 1), 200));
|
|
82
|
+
return rows.map(toSummary);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getCallSession(
|
|
86
|
+
db: Database,
|
|
87
|
+
localActorApId: string,
|
|
88
|
+
callId: string,
|
|
89
|
+
): Promise<CallSessionSummary | null> {
|
|
90
|
+
const row = await db
|
|
91
|
+
.select()
|
|
92
|
+
.from(callSessions)
|
|
93
|
+
.where(
|
|
94
|
+
and(
|
|
95
|
+
eq(callSessions.id, callId),
|
|
96
|
+
eq(callSessions.localActorApId, localActorApId),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
.get();
|
|
100
|
+
return row ? toSummary(row) : null;
|
|
101
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RtcProvider — media-plane configuration for the call feature.
|
|
3
|
+
*
|
|
4
|
+
* This is the seam that keeps calling vendor-neutral: 1:1 calls are pure P2P
|
|
5
|
+
* WebRTC over the operator-configured STUN/TURN servers (no SFU, no Cloudflare),
|
|
6
|
+
* and group calls (Phase 3) select a WHIP/WHEP SFU "focus" whose backend is any
|
|
7
|
+
* of `whip` / `livekit` / `cloudflare-realtime` — Cloudflare Realtime is one
|
|
8
|
+
* adapter among equals, never required.
|
|
9
|
+
*
|
|
10
|
+
* TURN credentials, when coturn's REST scheme is configured, are minted
|
|
11
|
+
* per-request as short-lived HMAC creds (RFC 8489 long-term-credential via the
|
|
12
|
+
* `turn-rest` `timestamp:name` username convention) — no static long-lived
|
|
13
|
+
* secret is ever handed to a client.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
CallMediaKind,
|
|
18
|
+
IceServerConfig,
|
|
19
|
+
SfuFocus,
|
|
20
|
+
} from "../../../../packages/api/src/types/call.ts";
|
|
21
|
+
import type { EnvVars } from "../../types.ts";
|
|
22
|
+
import { bufferToBase64 } from "../base64.ts";
|
|
23
|
+
import { logger } from "../logger.ts";
|
|
24
|
+
|
|
25
|
+
const log = logger.child({ component: "rtc.provider" });
|
|
26
|
+
|
|
27
|
+
const DEFAULT_TURN_TTL_SECONDS = 3600;
|
|
28
|
+
|
|
29
|
+
export interface RtcProvider {
|
|
30
|
+
/** ICE (STUN/TURN) servers for a call. Fresh (short-lived) TURN creds. */
|
|
31
|
+
getIceServers(): Promise<IceServerConfig[]>;
|
|
32
|
+
/** Group-call SFU focus, or null for pure P2P (always null for 1:1). */
|
|
33
|
+
getSfuFocus(media: CallMediaKind): Promise<SfuFocus | null>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseStaticIceServers(raw: string | undefined): IceServerConfig[] {
|
|
37
|
+
if (!raw?.trim()) return [];
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!Array.isArray(parsed)) return [];
|
|
41
|
+
const out: IceServerConfig[] = [];
|
|
42
|
+
for (const entry of parsed) {
|
|
43
|
+
if (!entry || typeof entry !== "object") continue;
|
|
44
|
+
const urls = (entry as { urls?: unknown }).urls;
|
|
45
|
+
if (typeof urls !== "string" && !Array.isArray(urls)) continue;
|
|
46
|
+
const server: IceServerConfig = { urls: urls as string | string[] };
|
|
47
|
+
const username = (entry as { username?: unknown }).username;
|
|
48
|
+
const credential = (entry as { credential?: unknown }).credential;
|
|
49
|
+
if (typeof username === "string") server.username = username;
|
|
50
|
+
if (typeof credential === "string") server.credential = credential;
|
|
51
|
+
out.push(server);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
log.warn("Invalid YURUCOMMU_RTC_ICE_SERVERS JSON", { error: String(err) });
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function mintTurnCredential(
|
|
61
|
+
uris: string[],
|
|
62
|
+
secret: string,
|
|
63
|
+
ttlSeconds: number,
|
|
64
|
+
): Promise<IceServerConfig | null> {
|
|
65
|
+
if (uris.length === 0 || !secret) return null;
|
|
66
|
+
// coturn REST: username = "<unix-expiry>:<name>", credential = base64(HMAC-
|
|
67
|
+
// SHA1(secret, username)). A random name keeps creds unlinkable per call.
|
|
68
|
+
const expiry = Math.floor(Date.now() / 1000) + ttlSeconds;
|
|
69
|
+
const username = `${expiry}:yurucommu`;
|
|
70
|
+
try {
|
|
71
|
+
const key = await crypto.subtle.importKey(
|
|
72
|
+
"raw",
|
|
73
|
+
new TextEncoder().encode(secret),
|
|
74
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
75
|
+
false,
|
|
76
|
+
["sign"],
|
|
77
|
+
);
|
|
78
|
+
const mac = await crypto.subtle.sign(
|
|
79
|
+
"HMAC",
|
|
80
|
+
key,
|
|
81
|
+
new TextEncoder().encode(username),
|
|
82
|
+
);
|
|
83
|
+
return { urls: uris, username, credential: bufferToBase64(mac) };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
log.error("Failed to mint TURN credential", { error: String(err) });
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class ConfiguredRtcProvider implements RtcProvider {
|
|
91
|
+
constructor(private readonly env: EnvVars) {}
|
|
92
|
+
|
|
93
|
+
async getIceServers(): Promise<IceServerConfig[]> {
|
|
94
|
+
const servers = parseStaticIceServers(this.env.YURUCOMMU_RTC_ICE_SERVERS);
|
|
95
|
+
const uris = (this.env.YURUCOMMU_RTC_TURN_URIS ?? "")
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((s) => s.trim())
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
const secret = this.env.YURUCOMMU_RTC_TURN_SECRET?.trim();
|
|
100
|
+
if (uris.length > 0 && secret) {
|
|
101
|
+
const ttl =
|
|
102
|
+
Number.parseInt(this.env.YURUCOMMU_RTC_TURN_TTL ?? "", 10) ||
|
|
103
|
+
DEFAULT_TURN_TTL_SECONDS;
|
|
104
|
+
const turn = await mintTurnCredential(uris, secret, ttl);
|
|
105
|
+
if (turn) servers.push(turn);
|
|
106
|
+
}
|
|
107
|
+
return servers;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async getSfuFocus(_media: CallMediaKind): Promise<SfuFocus | null> {
|
|
111
|
+
const adapter = (this.env.YURUCOMMU_RTC_SFU_ADAPTER ?? "p2p")
|
|
112
|
+
.trim()
|
|
113
|
+
.toLowerCase();
|
|
114
|
+
if (adapter === "" || adapter === "p2p") return null;
|
|
115
|
+
const url = this.env.YURUCOMMU_RTC_SFU_URL?.trim();
|
|
116
|
+
if (!url) {
|
|
117
|
+
log.warn("SFU adapter selected but YURUCOMMU_RTC_SFU_URL unset", {
|
|
118
|
+
adapter,
|
|
119
|
+
});
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
// WHIP/WHEP focus passthrough. Real per-room token minting (LiveKit JWT,
|
|
123
|
+
// Cloudflare Realtime app tokens) lands with group calls (Phase 3); today
|
|
124
|
+
// the shared/static token (if any) is advertised as-is.
|
|
125
|
+
return {
|
|
126
|
+
kind: adapter,
|
|
127
|
+
url,
|
|
128
|
+
token: this.env.YURUCOMMU_RTC_SFU_TOKEN?.trim() || undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createRtcProvider(env: EnvVars): RtcProvider {
|
|
134
|
+
return new ConfiguredRtcProvider(env);
|
|
135
|
+
}
|