@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,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-neutral call signaling engine.
|
|
3
|
+
*
|
|
4
|
+
* `CallHub` owns one local actor's per-call state machine and routes signaling
|
|
5
|
+
* frames between the local browser tabs and the peer instance (server-to-server).
|
|
6
|
+
* It is deliberately free of Cloudflare / Bun APIs: the Cloudflare
|
|
7
|
+
* `CallSignalingDurableObject` and the in-process `LocalSignalingHub` (Bun dev /
|
|
8
|
+
* tests) both wrap this same engine, so glare resolution, timeouts, and relay
|
|
9
|
+
* live in exactly one place.
|
|
10
|
+
*
|
|
11
|
+
* Connections are NOT held by the hub — the host owns them (the DO enumerates
|
|
12
|
+
* its live Hibernatable WebSockets on demand) and exposes `broadcast`/`hasClients`
|
|
13
|
+
* through the port. That keeps the hub correct across DO hibernation, where any
|
|
14
|
+
* in-memory socket set would be lost. Per-call state is persisted by the host
|
|
15
|
+
* (`persist`) and rehydrated via `hydrate`.
|
|
16
|
+
*
|
|
17
|
+
* The wire contract lives in the client-API package (single source of truth,
|
|
18
|
+
* shared with the browser `CallClient`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
CallDirection,
|
|
23
|
+
CallMediaKind,
|
|
24
|
+
CallState,
|
|
25
|
+
ClientToHubFrame,
|
|
26
|
+
HubToClientFrame,
|
|
27
|
+
IceServerConfig,
|
|
28
|
+
RtcSignalEnvelopeV1,
|
|
29
|
+
SfuFocus,
|
|
30
|
+
} from "../../../packages/api/src/types/call.ts";
|
|
31
|
+
import {
|
|
32
|
+
isEnvelopeFresh,
|
|
33
|
+
isTerminalCallState,
|
|
34
|
+
RTC_SIGNAL_ENVELOPE_VERSION,
|
|
35
|
+
} from "../../../packages/api/src/types/call.ts";
|
|
36
|
+
|
|
37
|
+
/** A live browser WebSocket, abstracted from the runtime. */
|
|
38
|
+
export interface HubConnection {
|
|
39
|
+
send(frame: HubToClientFrame): void;
|
|
40
|
+
close(code?: number, reason?: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** In-flight call, tracked per hub (= per local actor). */
|
|
44
|
+
export interface CallRecord {
|
|
45
|
+
callId: string;
|
|
46
|
+
peerApId: string;
|
|
47
|
+
peerSignalEndpoint?: string;
|
|
48
|
+
direction: CallDirection;
|
|
49
|
+
state: CallState;
|
|
50
|
+
media: CallMediaKind;
|
|
51
|
+
sfuFocus: SfuFocus | null;
|
|
52
|
+
createdAt: number;
|
|
53
|
+
updatedAt: number;
|
|
54
|
+
connectedAt?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Side-effect port the hosting runtime supplies. */
|
|
58
|
+
export interface HubPort {
|
|
59
|
+
/** The local actor (ap_id) this hub serves. */
|
|
60
|
+
readonly localActorApId: string;
|
|
61
|
+
/** Send a frame to every live local browser tab. */
|
|
62
|
+
broadcast(frame: HubToClientFrame): void;
|
|
63
|
+
/** Whether any local browser tab is currently connected. */
|
|
64
|
+
hasClients(): boolean;
|
|
65
|
+
/** Sign + POST a signaling envelope to the peer's instance (s2s). */
|
|
66
|
+
sendToPeer(
|
|
67
|
+
envelope: RtcSignalEnvelopeV1,
|
|
68
|
+
peerSignalEndpoint?: string,
|
|
69
|
+
): Promise<void>;
|
|
70
|
+
/** ICE servers (+ optional SFU focus) for a call. */
|
|
71
|
+
provisionMedia(
|
|
72
|
+
media: CallMediaKind,
|
|
73
|
+
): Promise<{ iceServers: IceServerConfig[]; sfuFocus: SfuFocus | null }>;
|
|
74
|
+
/** Best-effort persist of a call-session transition (history / missed-call). */
|
|
75
|
+
persist?(call: CallRecord): Promise<void> | void;
|
|
76
|
+
/** Best-effort wake of a possibly-offline callee (push-gateway ring). */
|
|
77
|
+
ring?(envelope: RtcSignalEnvelopeV1): Promise<void> | void;
|
|
78
|
+
now(): number;
|
|
79
|
+
log?(event: string, data?: Record<string, unknown>): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Ringing that never gets answered becomes a missed call; a call that never
|
|
83
|
+
// finishes ICE negotiation is failed. Kept generous — a slow federation hop
|
|
84
|
+
// plus a human deciding to answer can legitimately take tens of seconds.
|
|
85
|
+
const RINGING_TIMEOUT_MS = 45_000;
|
|
86
|
+
const CONNECTING_TIMEOUT_MS = 40_000;
|
|
87
|
+
// Default freshness window stamped on outbound envelopes.
|
|
88
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
89
|
+
|
|
90
|
+
export class CallHub {
|
|
91
|
+
private readonly calls = new Map<string, CallRecord>();
|
|
92
|
+
|
|
93
|
+
constructor(private readonly port: HubPort) {}
|
|
94
|
+
|
|
95
|
+
get localActorApId(): string {
|
|
96
|
+
return this.port.localActorApId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Snapshot of active (non-terminal) calls — used by the DO to persist. */
|
|
100
|
+
activeCalls(): CallRecord[] {
|
|
101
|
+
return [...this.calls.values()].filter((c) => !isTerminalCallState(c.state));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Restore calls from durable storage after DO hibernation. */
|
|
105
|
+
hydrate(records: CallRecord[]): void {
|
|
106
|
+
for (const rec of records) {
|
|
107
|
+
if (!isTerminalCallState(rec.state)) this.calls.set(rec.callId, rec);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private transition(call: CallRecord, state: CallState): void {
|
|
112
|
+
call.state = state;
|
|
113
|
+
call.updatedAt = this.port.now();
|
|
114
|
+
if (state === "connected" && !call.connectedAt) {
|
|
115
|
+
call.connectedAt = call.updatedAt;
|
|
116
|
+
}
|
|
117
|
+
void this.port.persist?.(call);
|
|
118
|
+
this.port.broadcast({ t: "call-state", callId: call.callId, state });
|
|
119
|
+
if (isTerminalCallState(state)) this.calls.delete(call.callId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private makeEnvelope(
|
|
123
|
+
call: CallRecord,
|
|
124
|
+
type: RtcSignalEnvelopeV1["type"],
|
|
125
|
+
extra: Partial<RtcSignalEnvelopeV1> = {},
|
|
126
|
+
): RtcSignalEnvelopeV1 {
|
|
127
|
+
return {
|
|
128
|
+
v: RTC_SIGNAL_ENVELOPE_VERSION,
|
|
129
|
+
callId: call.callId,
|
|
130
|
+
from: this.port.localActorApId,
|
|
131
|
+
to: call.peerApId,
|
|
132
|
+
type,
|
|
133
|
+
ts: this.port.now(),
|
|
134
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
135
|
+
...extra,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async relay(
|
|
140
|
+
call: CallRecord,
|
|
141
|
+
type: RtcSignalEnvelopeV1["type"],
|
|
142
|
+
extra: Partial<RtcSignalEnvelopeV1> = {},
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
try {
|
|
145
|
+
await this.port.sendToPeer(
|
|
146
|
+
this.makeEnvelope(call, type, extra),
|
|
147
|
+
call.peerSignalEndpoint,
|
|
148
|
+
);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
this.port.log?.("call.hub.relay_failed", {
|
|
151
|
+
callId: call.callId,
|
|
152
|
+
type,
|
|
153
|
+
error: String(err),
|
|
154
|
+
});
|
|
155
|
+
// A signaling frame we cannot deliver dooms the call; surface it.
|
|
156
|
+
this.port.broadcast({
|
|
157
|
+
t: "error",
|
|
158
|
+
code: "peer_unreachable",
|
|
159
|
+
message: "Could not reach the other party's server.",
|
|
160
|
+
});
|
|
161
|
+
this.transition(call, "failed");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// -------------------------------------------------------------------------
|
|
166
|
+
// Browser -> hub
|
|
167
|
+
// -------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
async handleClientFrame(
|
|
170
|
+
conn: HubConnection,
|
|
171
|
+
frame: ClientToHubFrame,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
switch (frame.t) {
|
|
174
|
+
case "hello":
|
|
175
|
+
conn.send({ t: "ready" });
|
|
176
|
+
// Re-announce any active calls so a reconnecting tab resyncs.
|
|
177
|
+
for (const call of this.activeCalls()) {
|
|
178
|
+
conn.send({ t: "call-state", callId: call.callId, state: call.state });
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
case "ping":
|
|
182
|
+
conn.send({ t: "pong" });
|
|
183
|
+
return;
|
|
184
|
+
case "invite":
|
|
185
|
+
return this.onClientInvite(conn, frame);
|
|
186
|
+
case "offer":
|
|
187
|
+
return this.onClientOffer(frame);
|
|
188
|
+
case "answer":
|
|
189
|
+
return this.onClientAnswer(frame);
|
|
190
|
+
case "candidates":
|
|
191
|
+
return this.onClientCandidates(frame);
|
|
192
|
+
case "accept":
|
|
193
|
+
return this.onClientAccept(frame);
|
|
194
|
+
case "reject":
|
|
195
|
+
return this.onClientReject(frame);
|
|
196
|
+
case "hangup":
|
|
197
|
+
return this.onClientHangup(frame);
|
|
198
|
+
case "resume":
|
|
199
|
+
return this.onClientResume(conn, frame);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async onClientInvite(
|
|
204
|
+
conn: HubConnection,
|
|
205
|
+
frame: Extract<ClientToHubFrame, { t: "invite" }>,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
if (this.calls.has(frame.callId)) return;
|
|
208
|
+
const media = await this.port.provisionMedia(frame.media);
|
|
209
|
+
const now = this.port.now();
|
|
210
|
+
const call: CallRecord = {
|
|
211
|
+
callId: frame.callId,
|
|
212
|
+
peerApId: frame.to,
|
|
213
|
+
direction: "outgoing",
|
|
214
|
+
state: "ringing",
|
|
215
|
+
media: frame.media,
|
|
216
|
+
sfuFocus: media.sfuFocus,
|
|
217
|
+
createdAt: now,
|
|
218
|
+
updatedAt: now,
|
|
219
|
+
};
|
|
220
|
+
this.calls.set(call.callId, call);
|
|
221
|
+
void this.port.persist?.(call);
|
|
222
|
+
// Hand the caller its media params immediately so it can build the offer.
|
|
223
|
+
conn.send({
|
|
224
|
+
t: "ice-servers",
|
|
225
|
+
callId: call.callId,
|
|
226
|
+
iceServers: media.iceServers,
|
|
227
|
+
sfuFocus: media.sfuFocus,
|
|
228
|
+
});
|
|
229
|
+
this.port.broadcast({
|
|
230
|
+
t: "call-state",
|
|
231
|
+
callId: call.callId,
|
|
232
|
+
state: "ringing",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async onClientOffer(
|
|
237
|
+
frame: Extract<ClientToHubFrame, { t: "offer" }>,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
const call = this.calls.get(frame.callId);
|
|
240
|
+
if (!call) return;
|
|
241
|
+
await this.relay(call, "offer", { sdp: frame.sdp, media: call.media });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async onClientAnswer(
|
|
245
|
+
frame: Extract<ClientToHubFrame, { t: "answer" }>,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
const call = this.calls.get(frame.callId);
|
|
248
|
+
if (!call) return;
|
|
249
|
+
if (call.state === "ringing") this.transition(call, "connecting");
|
|
250
|
+
await this.relay(call, "answer", { sdp: frame.sdp });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async onClientCandidates(
|
|
254
|
+
frame: Extract<ClientToHubFrame, { t: "candidates" }>,
|
|
255
|
+
): Promise<void> {
|
|
256
|
+
const call = this.calls.get(frame.callId);
|
|
257
|
+
if (!call || frame.candidates.length === 0) return;
|
|
258
|
+
await this.relay(call, "candidate", { candidates: frame.candidates });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async onClientAccept(
|
|
262
|
+
frame: Extract<ClientToHubFrame, { t: "accept" }>,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const call = this.calls.get(frame.callId);
|
|
265
|
+
if (!call) return;
|
|
266
|
+
// Local user accepted an incoming ring; tell the caller and move forward.
|
|
267
|
+
if (call.state === "ringing") this.transition(call, "connecting");
|
|
268
|
+
await this.relay(call, "accept");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async onClientReject(
|
|
272
|
+
frame: Extract<ClientToHubFrame, { t: "reject" }>,
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
const call = this.calls.get(frame.callId);
|
|
275
|
+
if (!call) return;
|
|
276
|
+
await this.relay(call, "reject", { reason: frame.reason });
|
|
277
|
+
this.transition(call, "rejected");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async onClientHangup(
|
|
281
|
+
frame: Extract<ClientToHubFrame, { t: "hangup" }>,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
const call = this.calls.get(frame.callId);
|
|
284
|
+
if (!call) return;
|
|
285
|
+
// A hangup before connection from the caller side is a cancel.
|
|
286
|
+
const wasConnected = call.state === "connected";
|
|
287
|
+
await this.relay(call, wasConnected ? "hangup" : "cancel", {
|
|
288
|
+
reason: frame.reason,
|
|
289
|
+
});
|
|
290
|
+
this.transition(call, wasConnected ? "ended" : "cancelled");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private onClientResume(
|
|
294
|
+
conn: HubConnection,
|
|
295
|
+
frame: Extract<ClientToHubFrame, { t: "resume" }>,
|
|
296
|
+
): void {
|
|
297
|
+
const call = this.calls.get(frame.callId);
|
|
298
|
+
conn.send({
|
|
299
|
+
t: "call-state",
|
|
300
|
+
callId: frame.callId,
|
|
301
|
+
state: call ? call.state : "ended",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -------------------------------------------------------------------------
|
|
306
|
+
// Peer instance -> hub (inbound cross-instance signal)
|
|
307
|
+
// -------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
async handleInboundSignal(envelope: RtcSignalEnvelopeV1): Promise<void> {
|
|
310
|
+
if (!isEnvelopeFresh(envelope, this.port.now())) {
|
|
311
|
+
this.port.log?.("call.hub.stale_signal", { callId: envelope.callId });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
switch (envelope.type) {
|
|
315
|
+
case "offer":
|
|
316
|
+
return this.onPeerOffer(envelope);
|
|
317
|
+
case "answer":
|
|
318
|
+
this.onPeerAnswer(envelope);
|
|
319
|
+
return;
|
|
320
|
+
case "candidate":
|
|
321
|
+
this.onPeerCandidate(envelope);
|
|
322
|
+
return;
|
|
323
|
+
case "accept":
|
|
324
|
+
this.onPeerAccept(envelope);
|
|
325
|
+
return;
|
|
326
|
+
case "reject":
|
|
327
|
+
this.onPeerReject(envelope);
|
|
328
|
+
return;
|
|
329
|
+
case "hangup":
|
|
330
|
+
case "cancel":
|
|
331
|
+
this.onPeerHangup(envelope);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private async onPeerOffer(envelope: RtcSignalEnvelopeV1): Promise<void> {
|
|
337
|
+
let call = this.calls.get(envelope.callId);
|
|
338
|
+
if (!call) {
|
|
339
|
+
// Glare: we are already ringing this same peer (a DIFFERENT callId) and
|
|
340
|
+
// their offer arrives — both sides dialed at once. Perfect Negotiation:
|
|
341
|
+
// the lexicographically-lower ap_id is "impolite" and keeps its own
|
|
342
|
+
// outgoing call (ignoring the incoming offer); the "polite" higher ap_id
|
|
343
|
+
// cancels its outgoing call and accepts the incoming one. Both sides then
|
|
344
|
+
// converge on the impolite side's call.
|
|
345
|
+
const outgoingToPeer = [...this.calls.values()].find(
|
|
346
|
+
(c) =>
|
|
347
|
+
c.direction === "outgoing" &&
|
|
348
|
+
c.peerApId === envelope.from &&
|
|
349
|
+
c.state === "ringing",
|
|
350
|
+
);
|
|
351
|
+
if (outgoingToPeer) {
|
|
352
|
+
const weArePolite = this.localActorApId > envelope.from;
|
|
353
|
+
if (!weArePolite) return; // keep our outgoing offer; ignore theirs
|
|
354
|
+
await this.relay(outgoingToPeer, "cancel", { reason: "glare" });
|
|
355
|
+
this.transition(outgoingToPeer, "cancelled");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (!call) {
|
|
359
|
+
const now = this.port.now();
|
|
360
|
+
call = {
|
|
361
|
+
callId: envelope.callId,
|
|
362
|
+
peerApId: envelope.from,
|
|
363
|
+
direction: "incoming",
|
|
364
|
+
state: "ringing",
|
|
365
|
+
media: envelope.media ?? { audio: true, video: false },
|
|
366
|
+
sfuFocus: envelope.sfuFocus ?? null,
|
|
367
|
+
createdAt: now,
|
|
368
|
+
updatedAt: now,
|
|
369
|
+
};
|
|
370
|
+
this.calls.set(call.callId, call);
|
|
371
|
+
void this.port.persist?.(call);
|
|
372
|
+
}
|
|
373
|
+
// Wake an offline client (best effort) and ring any live ones.
|
|
374
|
+
void this.port.ring?.(envelope);
|
|
375
|
+
if (this.port.hasClients()) {
|
|
376
|
+
const media = await this.port.provisionMedia(call.media);
|
|
377
|
+
this.port.broadcast({
|
|
378
|
+
t: "ice-servers",
|
|
379
|
+
callId: call.callId,
|
|
380
|
+
iceServers: media.iceServers,
|
|
381
|
+
sfuFocus: media.sfuFocus,
|
|
382
|
+
});
|
|
383
|
+
this.port.broadcast({
|
|
384
|
+
t: "ringing",
|
|
385
|
+
callId: call.callId,
|
|
386
|
+
from: call.peerApId,
|
|
387
|
+
media: call.media,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
if (envelope.sdp) {
|
|
391
|
+
this.port.broadcast({
|
|
392
|
+
t: "offer",
|
|
393
|
+
callId: call.callId,
|
|
394
|
+
sdp: envelope.sdp,
|
|
395
|
+
media: call.media,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private onPeerAnswer(envelope: RtcSignalEnvelopeV1): void {
|
|
401
|
+
const call = this.calls.get(envelope.callId);
|
|
402
|
+
if (!call || !envelope.sdp) return;
|
|
403
|
+
if (call.state === "ringing") this.transition(call, "connecting");
|
|
404
|
+
this.port.broadcast({ t: "answer", callId: call.callId, sdp: envelope.sdp });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private onPeerCandidate(envelope: RtcSignalEnvelopeV1): void {
|
|
408
|
+
const call = this.calls.get(envelope.callId);
|
|
409
|
+
if (!call || !envelope.candidates?.length) return;
|
|
410
|
+
this.port.broadcast({
|
|
411
|
+
t: "candidates",
|
|
412
|
+
callId: call.callId,
|
|
413
|
+
candidates: envelope.candidates,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private onPeerAccept(envelope: RtcSignalEnvelopeV1): void {
|
|
418
|
+
const call = this.calls.get(envelope.callId);
|
|
419
|
+
if (!call) return;
|
|
420
|
+
if (call.state === "ringing") this.transition(call, "connecting");
|
|
421
|
+
this.port.broadcast({ t: "peer-accepted", callId: call.callId });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private onPeerReject(envelope: RtcSignalEnvelopeV1): void {
|
|
425
|
+
const call = this.calls.get(envelope.callId);
|
|
426
|
+
if (!call) return;
|
|
427
|
+
this.port.broadcast({
|
|
428
|
+
t: "peer-rejected",
|
|
429
|
+
callId: call.callId,
|
|
430
|
+
reason: envelope.reason,
|
|
431
|
+
});
|
|
432
|
+
this.transition(call, "rejected");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private onPeerHangup(envelope: RtcSignalEnvelopeV1): void {
|
|
436
|
+
const call = this.calls.get(envelope.callId);
|
|
437
|
+
if (!call) return;
|
|
438
|
+
this.port.broadcast({
|
|
439
|
+
t: "peer-hangup",
|
|
440
|
+
callId: call.callId,
|
|
441
|
+
reason: envelope.reason,
|
|
442
|
+
});
|
|
443
|
+
this.transition(call, call.connectedAt ? "ended" : "cancelled");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Promote a call to connected (client signals ICE established). */
|
|
447
|
+
markConnected(callId: string): void {
|
|
448
|
+
const call = this.calls.get(callId);
|
|
449
|
+
if (call && call.state !== "connected") this.transition(call, "connected");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// -------------------------------------------------------------------------
|
|
453
|
+
// Periodic sweep (DO alarm / dev interval) — expire stuck calls
|
|
454
|
+
// -------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
tick(): void {
|
|
457
|
+
const now = this.port.now();
|
|
458
|
+
for (const call of [...this.calls.values()]) {
|
|
459
|
+
const age = now - call.updatedAt;
|
|
460
|
+
if (call.state === "ringing" && age > RINGING_TIMEOUT_MS) {
|
|
461
|
+
this.transition(
|
|
462
|
+
call,
|
|
463
|
+
call.direction === "incoming" ? "missed" : "cancelled",
|
|
464
|
+
);
|
|
465
|
+
} else if (call.state === "connecting" && age > CONNECTING_TIMEOUT_MS) {
|
|
466
|
+
this.transition(call, "failed");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HubPort construction. Both the Cloudflare DO and the in-process Bun
|
|
3
|
+
* hub build their `HubPort` from these deps, so signer loading, media
|
|
4
|
+
* provisioning, and durable persistence are single-sourced.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { eq } from "drizzle-orm";
|
|
8
|
+
import type { Database } from "../../db/index.ts";
|
|
9
|
+
import { actors } from "../../db/index.ts";
|
|
10
|
+
import type { EnvVars } from "../types.ts";
|
|
11
|
+
import type { RtcSignalEnvelopeV1 } from "../../../packages/api/src/types/call.ts";
|
|
12
|
+
import type { CallRecord, HubConnection, HubPort } from "./call-hub-core.ts";
|
|
13
|
+
import {
|
|
14
|
+
type CallSigner,
|
|
15
|
+
sendCallSignal,
|
|
16
|
+
} from "../lib/rtc/signal-transport.ts";
|
|
17
|
+
import { createRtcProvider } from "../lib/rtc/provider.ts";
|
|
18
|
+
import { upsertCallSession } from "../lib/rtc/call-store.ts";
|
|
19
|
+
import { logger } from "../lib/logger.ts";
|
|
20
|
+
|
|
21
|
+
const log = logger.child({ component: "call.hub" });
|
|
22
|
+
|
|
23
|
+
export interface CallHubPortDeps {
|
|
24
|
+
localActorApId: string;
|
|
25
|
+
db: Database;
|
|
26
|
+
env: EnvVars;
|
|
27
|
+
broadcast(frame: Parameters<HubConnection["send"]>[0]): void;
|
|
28
|
+
hasClients(): boolean;
|
|
29
|
+
ring?(envelope: RtcSignalEnvelopeV1): Promise<void> | void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createCallHubPort(deps: CallHubPortDeps): HubPort {
|
|
33
|
+
const provider = createRtcProvider(deps.env);
|
|
34
|
+
let signer: CallSigner | null = null;
|
|
35
|
+
|
|
36
|
+
const loadSigner = async (): Promise<CallSigner> => {
|
|
37
|
+
if (signer) return signer;
|
|
38
|
+
const row = await deps.db.query.actors.findFirst({
|
|
39
|
+
where: eq(actors.apId, deps.localActorApId),
|
|
40
|
+
columns: { apId: true, privateKeyPem: true },
|
|
41
|
+
});
|
|
42
|
+
if (!row?.privateKeyPem) {
|
|
43
|
+
throw new Error(`no signing key for local actor ${deps.localActorApId}`);
|
|
44
|
+
}
|
|
45
|
+
signer = { apId: row.apId, privateKeyPem: row.privateKeyPem };
|
|
46
|
+
return signer;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
localActorApId: deps.localActorApId,
|
|
51
|
+
broadcast: deps.broadcast,
|
|
52
|
+
hasClients: deps.hasClients,
|
|
53
|
+
ring: deps.ring,
|
|
54
|
+
now: () => Date.now(),
|
|
55
|
+
log: (event, data) => log.info(event, data),
|
|
56
|
+
async sendToPeer(envelope, peerSignalEndpoint) {
|
|
57
|
+
const s = await loadSigner();
|
|
58
|
+
await sendCallSignal(deps.db, s, envelope, peerSignalEndpoint);
|
|
59
|
+
},
|
|
60
|
+
async provisionMedia(media) {
|
|
61
|
+
const [iceServers, sfuFocus] = await Promise.all([
|
|
62
|
+
provider.getIceServers(),
|
|
63
|
+
provider.getSfuFocus(media),
|
|
64
|
+
]);
|
|
65
|
+
return { iceServers, sfuFocus };
|
|
66
|
+
},
|
|
67
|
+
async persist(call: CallRecord) {
|
|
68
|
+
try {
|
|
69
|
+
await upsertCallSession(deps.db, deps.localActorApId, call);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
log.warn("call session persist failed", {
|
|
72
|
+
callId: call.callId,
|
|
73
|
+
error: String(err),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|