@takosjp/yurucommu-core 3.2.1 → 3.4.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 +2 -0
- package/packages/api/src/lib/rtc-client.ts +540 -0
- package/packages/api/src/types/call.ts +298 -0
- package/packages/api/src/types/index.ts +6 -0
- package/packages/api/src/types/realtime.ts +139 -0
- package/src/backend/index.ts +32 -1
- package/src/backend/lib/delivery/queue.ts +4 -0
- 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/lib/unread-counts.ts +63 -2
- package/src/backend/public.ts +6 -0
- package/src/backend/routes/activitypub.ts +5 -1
- package/src/backend/routes/auth.ts +8 -2
- package/src/backend/routes/communities/messages.ts +53 -17
- package/src/backend/routes/dm/messages.ts +47 -7
- package/src/backend/routes/dm/read-archive.ts +27 -0
- package/src/backend/routes/dm/typing.ts +16 -0
- package/src/backend/routes/notifications.ts +25 -0
- package/src/backend/routes/realtime/index.ts +67 -0
- package/src/backend/routes/rtc/index.ts +151 -0
- package/src/backend/runtime/call-hub-core.ts +480 -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/realtime-hub.ts +257 -0
- package/src/backend/runtime/realtime-stream-do.ts +323 -0
- package/src/backend/runtime/signaling-hub.ts +187 -0
- package/src/backend/types.ts +32 -0
- package/src/db/schema/calls.ts +46 -0
- package/src/db/schema/index.ts +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime stream routes.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/realtime/config capability probe ({ available }) — clients
|
|
5
|
+
* gate their fallback polling on this
|
|
6
|
+
* POST /api/realtime/ticket mint a one-time short-lived WS ticket
|
|
7
|
+
* GET /api/realtime/socket browser WebSocket upgrade -> per-user stream
|
|
8
|
+
*
|
|
9
|
+
* Two upgrade auth paths, both terminating in the worker BEFORE the DO is
|
|
10
|
+
* reached (the DO binding is the trust boundary):
|
|
11
|
+
* - session: the same-origin browser sends its session cookie; the /api/*
|
|
12
|
+
* middleware resolved the actor already.
|
|
13
|
+
* - ticket: a cross-origin or bearer-auth client (the browser WebSocket API
|
|
14
|
+
* cannot set an Authorization header) first POSTs /ticket over the normal
|
|
15
|
+
* authenticated fetch path, then connects with ?actor=&ticket=. The ticket
|
|
16
|
+
* is minted inside — and re-verified + consumed by — the target user's own
|
|
17
|
+
* stream DO, so it is single-use, expires in ~60s, and never carries the
|
|
18
|
+
* raw session credential in a URL.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Hono } from "hono";
|
|
22
|
+
import type { Env, Variables } from "../../types.ts";
|
|
23
|
+
import {
|
|
24
|
+
getRealtimeHub,
|
|
25
|
+
isRealtimeAvailable,
|
|
26
|
+
} from "../../runtime/realtime-hub.ts";
|
|
27
|
+
|
|
28
|
+
const realtime = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
29
|
+
|
|
30
|
+
realtime.get("/config", (c) => {
|
|
31
|
+
return c.json({ available: isRealtimeAvailable(c.env) });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
realtime.post("/ticket", async (c) => {
|
|
35
|
+
const actor = c.get("actor");
|
|
36
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
37
|
+
if (!isRealtimeAvailable(c.env)) {
|
|
38
|
+
return c.json({ error: "realtime_unavailable" }, 503);
|
|
39
|
+
}
|
|
40
|
+
const ticket = await getRealtimeHub(c.env).mintTicket(actor.ap_id);
|
|
41
|
+
if (!ticket) return c.json({ error: "ticket_mint_failed" }, 500);
|
|
42
|
+
return c.json({ ticket, actor_ap_id: actor.ap_id });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
realtime.get("/socket", async (c) => {
|
|
46
|
+
if (!isRealtimeAvailable(c.env)) {
|
|
47
|
+
return c.json({ error: "realtime_unavailable" }, 503);
|
|
48
|
+
}
|
|
49
|
+
const hub = getRealtimeHub(c.env);
|
|
50
|
+
|
|
51
|
+
const sessionActor = c.get("actor");
|
|
52
|
+
if (sessionActor) {
|
|
53
|
+
return hub.upgrade(c.req.raw, sessionActor.ap_id, "session");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const actorParam = c.req.query("actor")?.trim();
|
|
57
|
+
const ticket = c.req.query("ticket")?.trim();
|
|
58
|
+
if (actorParam && ticket) {
|
|
59
|
+
// The actor param only selects WHICH stream DO verifies the ticket; a
|
|
60
|
+
// forged actor value fails inside that DO (it never minted the ticket).
|
|
61
|
+
return hub.upgrade(c.req.raw, actorParam, "ticket", ticket);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default realtime;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call feature routes (WebRTC voice + video).
|
|
3
|
+
*
|
|
4
|
+
* POST /ap/rtc/signal server-to-server signaling ingest (HTTP-Signature)
|
|
5
|
+
* GET /api/rtc/socket browser WebSocket upgrade -> per-user signaling hub
|
|
6
|
+
* GET /api/rtc/ice mint short-lived ICE (STUN/TURN) servers
|
|
7
|
+
* POST /api/rtc/calls start a call (block-list gate + callId + ICE)
|
|
8
|
+
* GET /api/rtc/calls call history (missed / recent)
|
|
9
|
+
* GET /api/rtc/calls/:id current state of one call
|
|
10
|
+
*
|
|
11
|
+
* Signaling is intentionally OUTSIDE the ActivityPub inbox pipeline: the
|
|
12
|
+
* `/ap/rtc/signal` endpoint bypasses `claimActivityForDispatch` /
|
|
13
|
+
* `parseActivity` (which would strip SDP/ICE and persist ephemeral frames to the
|
|
14
|
+
* `activities` ledger). It reuses the same HTTP-Signature auth every inbox uses.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from "hono";
|
|
18
|
+
import { eq } from "drizzle-orm";
|
|
19
|
+
import type { Env, Variables } from "../../types.ts";
|
|
20
|
+
import { actors } from "../../../db/index.ts";
|
|
21
|
+
import { verifyHttpSignature } from "../../lib/ap-verify.ts";
|
|
22
|
+
import {
|
|
23
|
+
isActorMismatch,
|
|
24
|
+
signingActorFromKeyId,
|
|
25
|
+
} from "../activitypub/inbox.ts";
|
|
26
|
+
import { isActorBlocked } from "../../lib/blocklist.ts";
|
|
27
|
+
import {
|
|
28
|
+
getSignalingHub,
|
|
29
|
+
isSignalingAvailable,
|
|
30
|
+
} from "../../runtime/signaling-hub.ts";
|
|
31
|
+
import { createRtcProvider } from "../../lib/rtc/provider.ts";
|
|
32
|
+
import { getCallSession, listCallSessions } from "../../lib/rtc/call-store.ts";
|
|
33
|
+
import type {
|
|
34
|
+
CallMediaKind,
|
|
35
|
+
StartCallRequest,
|
|
36
|
+
} from "../../../../packages/api/src/types/call.ts";
|
|
37
|
+
import { parseRtcSignalEnvelope } from "../../../../packages/api/src/types/call.ts";
|
|
38
|
+
|
|
39
|
+
const rtc = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
40
|
+
|
|
41
|
+
function normalizeMedia(input: unknown): CallMediaKind {
|
|
42
|
+
if (input && typeof input === "object") {
|
|
43
|
+
const m = input as Partial<CallMediaKind>;
|
|
44
|
+
return { audio: m.audio !== false, video: Boolean(m.video) };
|
|
45
|
+
}
|
|
46
|
+
return { audio: true, video: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Server-to-server signaling ingest -------------------------------------
|
|
50
|
+
rtc.post("/ap/rtc/signal", async (c) => {
|
|
51
|
+
const db = c.get("db");
|
|
52
|
+
const body = await c.req.text();
|
|
53
|
+
const sig = await verifyHttpSignature(c.req.raw, db, body);
|
|
54
|
+
if (!sig.valid) return c.json({ error: "invalid_signature" }, 401);
|
|
55
|
+
|
|
56
|
+
let parsed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(body);
|
|
59
|
+
} catch {
|
|
60
|
+
return c.json({ error: "bad_json" }, 400);
|
|
61
|
+
}
|
|
62
|
+
const envelope = parseRtcSignalEnvelope(parsed);
|
|
63
|
+
if (!envelope) return c.json({ error: "bad_envelope" }, 400);
|
|
64
|
+
|
|
65
|
+
// The HTTP-Signature signer must own the claimed `from` actor.
|
|
66
|
+
if (isActorMismatch(signingActorFromKeyId(sig.keyId), envelope.from)) {
|
|
67
|
+
return c.json({ error: "signer_mismatch" }, 403);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The recipient must be a local actor served by this instance.
|
|
71
|
+
const local = await db.query.actors.findFirst({
|
|
72
|
+
where: eq(actors.apId, envelope.to),
|
|
73
|
+
columns: { apId: true },
|
|
74
|
+
});
|
|
75
|
+
if (!local) return c.json({ error: "unknown_recipient" }, 404);
|
|
76
|
+
|
|
77
|
+
// Never ring for a sender the local owner has blocked; drop silently.
|
|
78
|
+
if (await isActorBlocked(db, envelope.from)) return c.body(null, 204);
|
|
79
|
+
|
|
80
|
+
await getSignalingHub(c.env).deliver(envelope.to, envelope);
|
|
81
|
+
return c.body(null, 204);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// --- Browser WebSocket upgrade ---------------------------------------------
|
|
85
|
+
rtc.get("/api/rtc/socket", async (c) => {
|
|
86
|
+
const actor = c.get("actor");
|
|
87
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
88
|
+
if (!isSignalingAvailable(c.env)) {
|
|
89
|
+
return c.json({ error: "signaling_unavailable" }, 503);
|
|
90
|
+
}
|
|
91
|
+
return getSignalingHub(c.env).upgrade(c.req.raw, actor.ap_id);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// --- ICE servers ------------------------------------------------------------
|
|
95
|
+
rtc.get("/api/rtc/ice", async (c) => {
|
|
96
|
+
const actor = c.get("actor");
|
|
97
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
98
|
+
const iceServers = await createRtcProvider(c.env).getIceServers();
|
|
99
|
+
return c.json({ iceServers });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// --- Start a call -----------------------------------------------------------
|
|
103
|
+
rtc.post("/api/rtc/calls", async (c) => {
|
|
104
|
+
const actor = c.get("actor");
|
|
105
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
106
|
+
if (!isSignalingAvailable(c.env)) {
|
|
107
|
+
return c.json({ error: "signaling_unavailable" }, 503);
|
|
108
|
+
}
|
|
109
|
+
const db = c.get("db");
|
|
110
|
+
let payload: Partial<StartCallRequest>;
|
|
111
|
+
try {
|
|
112
|
+
payload = (await c.req.json()) as Partial<StartCallRequest>;
|
|
113
|
+
} catch {
|
|
114
|
+
return c.json({ error: "bad_json" }, 400);
|
|
115
|
+
}
|
|
116
|
+
const to = typeof payload.to === "string" ? payload.to.trim() : "";
|
|
117
|
+
if (!to || to === actor.ap_id) return c.json({ error: "bad_target" }, 400);
|
|
118
|
+
const media = normalizeMedia(payload.media);
|
|
119
|
+
|
|
120
|
+
// Do not let the local owner place a call to a contact they have blocked.
|
|
121
|
+
if (await isActorBlocked(db, to)) return c.json({ error: "blocked" }, 403);
|
|
122
|
+
|
|
123
|
+
const provider = createRtcProvider(c.env);
|
|
124
|
+
const [iceServers, sfuFocus] = await Promise.all([
|
|
125
|
+
provider.getIceServers(),
|
|
126
|
+
provider.getSfuFocus(media),
|
|
127
|
+
]);
|
|
128
|
+
return c.json({ callId: crypto.randomUUID(), iceServers, sfuFocus });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// --- Call history + state ---------------------------------------------------
|
|
132
|
+
rtc.get("/api/rtc/calls", async (c) => {
|
|
133
|
+
const actor = c.get("actor");
|
|
134
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
135
|
+
const calls = await listCallSessions(c.get("db"), actor.ap_id);
|
|
136
|
+
return c.json({ calls });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
rtc.get("/api/rtc/calls/:id", async (c) => {
|
|
140
|
+
const actor = c.get("actor");
|
|
141
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
142
|
+
const call = await getCallSession(
|
|
143
|
+
c.get("db"),
|
|
144
|
+
actor.ap_id,
|
|
145
|
+
c.req.param("id"),
|
|
146
|
+
);
|
|
147
|
+
if (!call) return c.json({ error: "not_found" }, 404);
|
|
148
|
+
return c.json({ call });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export default rtc;
|
|
@@ -0,0 +1,480 @@
|
|
|
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(
|
|
102
|
+
(c) => !isTerminalCallState(c.state),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Restore calls from durable storage after DO hibernation. */
|
|
107
|
+
hydrate(records: CallRecord[]): void {
|
|
108
|
+
for (const rec of records) {
|
|
109
|
+
if (!isTerminalCallState(rec.state)) this.calls.set(rec.callId, rec);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private transition(call: CallRecord, state: CallState): void {
|
|
114
|
+
call.state = state;
|
|
115
|
+
call.updatedAt = this.port.now();
|
|
116
|
+
if (state === "connected" && !call.connectedAt) {
|
|
117
|
+
call.connectedAt = call.updatedAt;
|
|
118
|
+
}
|
|
119
|
+
void this.port.persist?.(call);
|
|
120
|
+
this.port.broadcast({ t: "call-state", callId: call.callId, state });
|
|
121
|
+
if (isTerminalCallState(state)) this.calls.delete(call.callId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private makeEnvelope(
|
|
125
|
+
call: CallRecord,
|
|
126
|
+
type: RtcSignalEnvelopeV1["type"],
|
|
127
|
+
extra: Partial<RtcSignalEnvelopeV1> = {},
|
|
128
|
+
): RtcSignalEnvelopeV1 {
|
|
129
|
+
return {
|
|
130
|
+
v: RTC_SIGNAL_ENVELOPE_VERSION,
|
|
131
|
+
callId: call.callId,
|
|
132
|
+
from: this.port.localActorApId,
|
|
133
|
+
to: call.peerApId,
|
|
134
|
+
type,
|
|
135
|
+
ts: this.port.now(),
|
|
136
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
137
|
+
...extra,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async relay(
|
|
142
|
+
call: CallRecord,
|
|
143
|
+
type: RtcSignalEnvelopeV1["type"],
|
|
144
|
+
extra: Partial<RtcSignalEnvelopeV1> = {},
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
try {
|
|
147
|
+
await this.port.sendToPeer(
|
|
148
|
+
this.makeEnvelope(call, type, extra),
|
|
149
|
+
call.peerSignalEndpoint,
|
|
150
|
+
);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
this.port.log?.("call.hub.relay_failed", {
|
|
153
|
+
callId: call.callId,
|
|
154
|
+
type,
|
|
155
|
+
error: String(err),
|
|
156
|
+
});
|
|
157
|
+
// A signaling frame we cannot deliver dooms the call; surface it.
|
|
158
|
+
this.port.broadcast({
|
|
159
|
+
t: "error",
|
|
160
|
+
code: "peer_unreachable",
|
|
161
|
+
message: "Could not reach the other party's server.",
|
|
162
|
+
});
|
|
163
|
+
this.transition(call, "failed");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// -------------------------------------------------------------------------
|
|
168
|
+
// Browser -> hub
|
|
169
|
+
// -------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
async handleClientFrame(
|
|
172
|
+
conn: HubConnection,
|
|
173
|
+
frame: ClientToHubFrame,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
switch (frame.t) {
|
|
176
|
+
case "hello":
|
|
177
|
+
conn.send({ t: "ready" });
|
|
178
|
+
// Re-announce any active calls so a reconnecting tab resyncs.
|
|
179
|
+
for (const call of this.activeCalls()) {
|
|
180
|
+
conn.send({
|
|
181
|
+
t: "call-state",
|
|
182
|
+
callId: call.callId,
|
|
183
|
+
state: call.state,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
case "ping":
|
|
188
|
+
conn.send({ t: "pong" });
|
|
189
|
+
return;
|
|
190
|
+
case "invite":
|
|
191
|
+
return this.onClientInvite(conn, frame);
|
|
192
|
+
case "offer":
|
|
193
|
+
return this.onClientOffer(frame);
|
|
194
|
+
case "answer":
|
|
195
|
+
return this.onClientAnswer(frame);
|
|
196
|
+
case "candidates":
|
|
197
|
+
return this.onClientCandidates(frame);
|
|
198
|
+
case "accept":
|
|
199
|
+
return this.onClientAccept(frame);
|
|
200
|
+
case "reject":
|
|
201
|
+
return this.onClientReject(frame);
|
|
202
|
+
case "hangup":
|
|
203
|
+
return this.onClientHangup(frame);
|
|
204
|
+
case "resume":
|
|
205
|
+
return this.onClientResume(conn, frame);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async onClientInvite(
|
|
210
|
+
conn: HubConnection,
|
|
211
|
+
frame: Extract<ClientToHubFrame, { t: "invite" }>,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
if (this.calls.has(frame.callId)) return;
|
|
214
|
+
const media = await this.port.provisionMedia(frame.media);
|
|
215
|
+
const now = this.port.now();
|
|
216
|
+
const call: CallRecord = {
|
|
217
|
+
callId: frame.callId,
|
|
218
|
+
peerApId: frame.to,
|
|
219
|
+
direction: "outgoing",
|
|
220
|
+
state: "ringing",
|
|
221
|
+
media: frame.media,
|
|
222
|
+
sfuFocus: media.sfuFocus,
|
|
223
|
+
createdAt: now,
|
|
224
|
+
updatedAt: now,
|
|
225
|
+
};
|
|
226
|
+
this.calls.set(call.callId, call);
|
|
227
|
+
void this.port.persist?.(call);
|
|
228
|
+
// Hand the caller its media params immediately so it can build the offer.
|
|
229
|
+
conn.send({
|
|
230
|
+
t: "ice-servers",
|
|
231
|
+
callId: call.callId,
|
|
232
|
+
iceServers: media.iceServers,
|
|
233
|
+
sfuFocus: media.sfuFocus,
|
|
234
|
+
});
|
|
235
|
+
this.port.broadcast({
|
|
236
|
+
t: "call-state",
|
|
237
|
+
callId: call.callId,
|
|
238
|
+
state: "ringing",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async onClientOffer(
|
|
243
|
+
frame: Extract<ClientToHubFrame, { t: "offer" }>,
|
|
244
|
+
): Promise<void> {
|
|
245
|
+
const call = this.calls.get(frame.callId);
|
|
246
|
+
if (!call) return;
|
|
247
|
+
await this.relay(call, "offer", { sdp: frame.sdp, media: call.media });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async onClientAnswer(
|
|
251
|
+
frame: Extract<ClientToHubFrame, { t: "answer" }>,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const call = this.calls.get(frame.callId);
|
|
254
|
+
if (!call) return;
|
|
255
|
+
if (call.state === "ringing") this.transition(call, "connecting");
|
|
256
|
+
await this.relay(call, "answer", { sdp: frame.sdp });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async onClientCandidates(
|
|
260
|
+
frame: Extract<ClientToHubFrame, { t: "candidates" }>,
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
const call = this.calls.get(frame.callId);
|
|
263
|
+
if (!call || frame.candidates.length === 0) return;
|
|
264
|
+
await this.relay(call, "candidate", { candidates: frame.candidates });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async onClientAccept(
|
|
268
|
+
frame: Extract<ClientToHubFrame, { t: "accept" }>,
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const call = this.calls.get(frame.callId);
|
|
271
|
+
if (!call) return;
|
|
272
|
+
// Local user accepted an incoming ring; tell the caller and move forward.
|
|
273
|
+
if (call.state === "ringing") this.transition(call, "connecting");
|
|
274
|
+
await this.relay(call, "accept");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async onClientReject(
|
|
278
|
+
frame: Extract<ClientToHubFrame, { t: "reject" }>,
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
const call = this.calls.get(frame.callId);
|
|
281
|
+
if (!call) return;
|
|
282
|
+
await this.relay(call, "reject", { reason: frame.reason });
|
|
283
|
+
this.transition(call, "rejected");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async onClientHangup(
|
|
287
|
+
frame: Extract<ClientToHubFrame, { t: "hangup" }>,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const call = this.calls.get(frame.callId);
|
|
290
|
+
if (!call) return;
|
|
291
|
+
// A hangup before connection from the caller side is a cancel.
|
|
292
|
+
const wasConnected = call.state === "connected";
|
|
293
|
+
await this.relay(call, wasConnected ? "hangup" : "cancel", {
|
|
294
|
+
reason: frame.reason,
|
|
295
|
+
});
|
|
296
|
+
this.transition(call, wasConnected ? "ended" : "cancelled");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private onClientResume(
|
|
300
|
+
conn: HubConnection,
|
|
301
|
+
frame: Extract<ClientToHubFrame, { t: "resume" }>,
|
|
302
|
+
): void {
|
|
303
|
+
const call = this.calls.get(frame.callId);
|
|
304
|
+
conn.send({
|
|
305
|
+
t: "call-state",
|
|
306
|
+
callId: frame.callId,
|
|
307
|
+
state: call ? call.state : "ended",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// -------------------------------------------------------------------------
|
|
312
|
+
// Peer instance -> hub (inbound cross-instance signal)
|
|
313
|
+
// -------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
async handleInboundSignal(envelope: RtcSignalEnvelopeV1): Promise<void> {
|
|
316
|
+
if (!isEnvelopeFresh(envelope, this.port.now())) {
|
|
317
|
+
this.port.log?.("call.hub.stale_signal", { callId: envelope.callId });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
switch (envelope.type) {
|
|
321
|
+
case "offer":
|
|
322
|
+
return this.onPeerOffer(envelope);
|
|
323
|
+
case "answer":
|
|
324
|
+
this.onPeerAnswer(envelope);
|
|
325
|
+
return;
|
|
326
|
+
case "candidate":
|
|
327
|
+
this.onPeerCandidate(envelope);
|
|
328
|
+
return;
|
|
329
|
+
case "accept":
|
|
330
|
+
this.onPeerAccept(envelope);
|
|
331
|
+
return;
|
|
332
|
+
case "reject":
|
|
333
|
+
this.onPeerReject(envelope);
|
|
334
|
+
return;
|
|
335
|
+
case "hangup":
|
|
336
|
+
case "cancel":
|
|
337
|
+
this.onPeerHangup(envelope);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private async onPeerOffer(envelope: RtcSignalEnvelopeV1): Promise<void> {
|
|
343
|
+
let call = this.calls.get(envelope.callId);
|
|
344
|
+
if (!call) {
|
|
345
|
+
// Glare: we are already ringing this same peer (a DIFFERENT callId) and
|
|
346
|
+
// their offer arrives — both sides dialed at once. Perfect Negotiation:
|
|
347
|
+
// the lexicographically-lower ap_id is "impolite" and keeps its own
|
|
348
|
+
// outgoing call (ignoring the incoming offer); the "polite" higher ap_id
|
|
349
|
+
// cancels its outgoing call and accepts the incoming one. Both sides then
|
|
350
|
+
// converge on the impolite side's call.
|
|
351
|
+
const outgoingToPeer = [...this.calls.values()].find(
|
|
352
|
+
(c) =>
|
|
353
|
+
c.direction === "outgoing" &&
|
|
354
|
+
c.peerApId === envelope.from &&
|
|
355
|
+
c.state === "ringing",
|
|
356
|
+
);
|
|
357
|
+
if (outgoingToPeer) {
|
|
358
|
+
const weArePolite = this.localActorApId > envelope.from;
|
|
359
|
+
if (!weArePolite) return; // keep our outgoing offer; ignore theirs
|
|
360
|
+
await this.relay(outgoingToPeer, "cancel", { reason: "glare" });
|
|
361
|
+
this.transition(outgoingToPeer, "cancelled");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (!call) {
|
|
365
|
+
const now = this.port.now();
|
|
366
|
+
call = {
|
|
367
|
+
callId: envelope.callId,
|
|
368
|
+
peerApId: envelope.from,
|
|
369
|
+
direction: "incoming",
|
|
370
|
+
state: "ringing",
|
|
371
|
+
media: envelope.media ?? { audio: true, video: false },
|
|
372
|
+
sfuFocus: envelope.sfuFocus ?? null,
|
|
373
|
+
createdAt: now,
|
|
374
|
+
updatedAt: now,
|
|
375
|
+
};
|
|
376
|
+
this.calls.set(call.callId, call);
|
|
377
|
+
void this.port.persist?.(call);
|
|
378
|
+
}
|
|
379
|
+
// Wake an offline client (best effort) and ring any live ones.
|
|
380
|
+
void this.port.ring?.(envelope);
|
|
381
|
+
if (this.port.hasClients()) {
|
|
382
|
+
const media = await this.port.provisionMedia(call.media);
|
|
383
|
+
this.port.broadcast({
|
|
384
|
+
t: "ice-servers",
|
|
385
|
+
callId: call.callId,
|
|
386
|
+
iceServers: media.iceServers,
|
|
387
|
+
sfuFocus: media.sfuFocus,
|
|
388
|
+
});
|
|
389
|
+
this.port.broadcast({
|
|
390
|
+
t: "ringing",
|
|
391
|
+
callId: call.callId,
|
|
392
|
+
from: call.peerApId,
|
|
393
|
+
media: call.media,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (envelope.sdp) {
|
|
397
|
+
this.port.broadcast({
|
|
398
|
+
t: "offer",
|
|
399
|
+
callId: call.callId,
|
|
400
|
+
sdp: envelope.sdp,
|
|
401
|
+
media: call.media,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private onPeerAnswer(envelope: RtcSignalEnvelopeV1): void {
|
|
407
|
+
const call = this.calls.get(envelope.callId);
|
|
408
|
+
if (!call || !envelope.sdp) return;
|
|
409
|
+
if (call.state === "ringing") this.transition(call, "connecting");
|
|
410
|
+
this.port.broadcast({
|
|
411
|
+
t: "answer",
|
|
412
|
+
callId: call.callId,
|
|
413
|
+
sdp: envelope.sdp,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private onPeerCandidate(envelope: RtcSignalEnvelopeV1): void {
|
|
418
|
+
const call = this.calls.get(envelope.callId);
|
|
419
|
+
if (!call || !envelope.candidates?.length) return;
|
|
420
|
+
this.port.broadcast({
|
|
421
|
+
t: "candidates",
|
|
422
|
+
callId: call.callId,
|
|
423
|
+
candidates: envelope.candidates,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private onPeerAccept(envelope: RtcSignalEnvelopeV1): void {
|
|
428
|
+
const call = this.calls.get(envelope.callId);
|
|
429
|
+
if (!call) return;
|
|
430
|
+
if (call.state === "ringing") this.transition(call, "connecting");
|
|
431
|
+
this.port.broadcast({ t: "peer-accepted", callId: call.callId });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private onPeerReject(envelope: RtcSignalEnvelopeV1): void {
|
|
435
|
+
const call = this.calls.get(envelope.callId);
|
|
436
|
+
if (!call) return;
|
|
437
|
+
this.port.broadcast({
|
|
438
|
+
t: "peer-rejected",
|
|
439
|
+
callId: call.callId,
|
|
440
|
+
reason: envelope.reason,
|
|
441
|
+
});
|
|
442
|
+
this.transition(call, "rejected");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private onPeerHangup(envelope: RtcSignalEnvelopeV1): void {
|
|
446
|
+
const call = this.calls.get(envelope.callId);
|
|
447
|
+
if (!call) return;
|
|
448
|
+
this.port.broadcast({
|
|
449
|
+
t: "peer-hangup",
|
|
450
|
+
callId: call.callId,
|
|
451
|
+
reason: envelope.reason,
|
|
452
|
+
});
|
|
453
|
+
this.transition(call, call.connectedAt ? "ended" : "cancelled");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Promote a call to connected (client signals ICE established). */
|
|
457
|
+
markConnected(callId: string): void {
|
|
458
|
+
const call = this.calls.get(callId);
|
|
459
|
+
if (call && call.state !== "connected") this.transition(call, "connected");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// -------------------------------------------------------------------------
|
|
463
|
+
// Periodic sweep (DO alarm / dev interval) — expire stuck calls
|
|
464
|
+
// -------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
tick(): void {
|
|
467
|
+
const now = this.port.now();
|
|
468
|
+
for (const call of [...this.calls.values()]) {
|
|
469
|
+
const age = now - call.updatedAt;
|
|
470
|
+
if (call.state === "ringing" && age > RINGING_TIMEOUT_MS) {
|
|
471
|
+
this.transition(
|
|
472
|
+
call,
|
|
473
|
+
call.direction === "incoming" ? "missed" : "cancelled",
|
|
474
|
+
);
|
|
475
|
+
} else if (call.state === "connecting" && age > CONNECTING_TIMEOUT_MS) {
|
|
476
|
+
this.transition(call, "failed");
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|