@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,298 @@
|
|
|
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" | "answer" | "candidate" | "accept" | "reject" | "hangup" | "cancel";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Selected SFU focus for a group call. `null`/absent means pure P2P (1:1).
|
|
34
|
+
* `kind` names the adapter (`whip` / `livekit` / `cloudflare-realtime` / ...);
|
|
35
|
+
* the client talks WHIP/WHEP so the SFU backend stays vendor-neutral.
|
|
36
|
+
*/
|
|
37
|
+
export interface SfuFocus {
|
|
38
|
+
kind: string;
|
|
39
|
+
/** WHIP (publish) / WHEP (subscribe) endpoint base, or SFU signaling URL. */
|
|
40
|
+
url: string;
|
|
41
|
+
/** Short-lived join token when the adapter requires one. */
|
|
42
|
+
token?: string;
|
|
43
|
+
room?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Structural mirror of `RTCIceCandidateInit` (no DOM dependency). */
|
|
47
|
+
export interface CallIceCandidate {
|
|
48
|
+
candidate: string;
|
|
49
|
+
sdpMid?: string | null;
|
|
50
|
+
sdpMLineIndex?: number | null;
|
|
51
|
+
usernameFragment?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Structural mirror of `RTCIceServer` (no DOM dependency). */
|
|
55
|
+
export interface IceServerConfig {
|
|
56
|
+
urls: string | string[];
|
|
57
|
+
username?: string;
|
|
58
|
+
credential?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Server-to-server signaling envelope. Delivered by the sending instance to the
|
|
63
|
+
* recipient instance's `/ap/rtc/signal` endpoint, signed with the sender actor's
|
|
64
|
+
* HTTP Signature key (keyId-owner === `from`). `callId` doubles as the anti-
|
|
65
|
+
* replay nonce; `ts`/`ttlMs` bound its freshness (the DO drops stale frames).
|
|
66
|
+
*/
|
|
67
|
+
export interface RtcSignalEnvelopeV1 {
|
|
68
|
+
v: typeof RTC_SIGNAL_ENVELOPE_VERSION;
|
|
69
|
+
callId: string;
|
|
70
|
+
from: string;
|
|
71
|
+
to: string;
|
|
72
|
+
type: RtcSignalType;
|
|
73
|
+
media?: CallMediaKind;
|
|
74
|
+
/** SDP for `offer` / `answer`. */
|
|
75
|
+
sdp?: string;
|
|
76
|
+
/** Half-trickle ICE bundle for `offer` / `answer` / `candidate`. */
|
|
77
|
+
candidates?: CallIceCandidate[];
|
|
78
|
+
sfuFocus?: SfuFocus | null;
|
|
79
|
+
/** Free-text end/reject reason (`busy`, `declined`, `timeout`, ...). */
|
|
80
|
+
reason?: string;
|
|
81
|
+
ts: number;
|
|
82
|
+
ttlMs: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Lifecycle of a single call, mirrored client-side and in `call_sessions`. */
|
|
86
|
+
export type CallState =
|
|
87
|
+
| "idle"
|
|
88
|
+
| "ringing"
|
|
89
|
+
| "connecting"
|
|
90
|
+
| "connected"
|
|
91
|
+
| "ended"
|
|
92
|
+
| "rejected"
|
|
93
|
+
| "missed"
|
|
94
|
+
| "failed"
|
|
95
|
+
| "cancelled";
|
|
96
|
+
|
|
97
|
+
export type CallDirection = "incoming" | "outgoing";
|
|
98
|
+
|
|
99
|
+
/** Terminal states — a call in one of these is over and not resumable. */
|
|
100
|
+
export const TERMINAL_CALL_STATES: readonly CallState[] = [
|
|
101
|
+
"ended",
|
|
102
|
+
"rejected",
|
|
103
|
+
"missed",
|
|
104
|
+
"failed",
|
|
105
|
+
"cancelled",
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
export function isTerminalCallState(state: CallState): boolean {
|
|
109
|
+
return TERMINAL_CALL_STATES.includes(state);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Browser <-> Signaling Durable Object WebSocket frames
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/** Frames the browser sends up to its own instance's Signaling DO. */
|
|
117
|
+
export type ClientToHubFrame =
|
|
118
|
+
| { t: "hello" }
|
|
119
|
+
| { t: "invite"; callId: string; to: string; media: CallMediaKind }
|
|
120
|
+
| { t: "offer"; callId: string; sdp: string }
|
|
121
|
+
| { t: "answer"; callId: string; sdp: string }
|
|
122
|
+
| { t: "candidates"; callId: string; candidates: CallIceCandidate[] }
|
|
123
|
+
| { t: "accept"; callId: string }
|
|
124
|
+
| { t: "reject"; callId: string; reason?: string }
|
|
125
|
+
| { t: "hangup"; callId: string; reason?: string }
|
|
126
|
+
| { t: "resume"; callId: string }
|
|
127
|
+
| { t: "ping" };
|
|
128
|
+
|
|
129
|
+
/** Frames the Signaling DO pushes down to the browser. */
|
|
130
|
+
export type HubToClientFrame =
|
|
131
|
+
| { t: "ready" }
|
|
132
|
+
| { t: "ringing"; callId: string; from: string; media: CallMediaKind }
|
|
133
|
+
| { t: "offer"; callId: string; sdp: string; media?: CallMediaKind }
|
|
134
|
+
| { t: "answer"; callId: string; sdp: string }
|
|
135
|
+
| { t: "candidates"; callId: string; candidates: CallIceCandidate[] }
|
|
136
|
+
| { t: "peer-accepted"; callId: string }
|
|
137
|
+
| { t: "peer-rejected"; callId: string; reason?: string }
|
|
138
|
+
| { t: "peer-hangup"; callId: string; reason?: string }
|
|
139
|
+
| {
|
|
140
|
+
t: "ice-servers";
|
|
141
|
+
callId: string;
|
|
142
|
+
iceServers: IceServerConfig[];
|
|
143
|
+
sfuFocus?: SfuFocus | null;
|
|
144
|
+
}
|
|
145
|
+
| { t: "call-state"; callId: string; state: CallState }
|
|
146
|
+
| { t: "pong" }
|
|
147
|
+
| { t: "error"; code: string; message?: string };
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// REST contract (start call / mint ICE / call history)
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
export interface StartCallRequest {
|
|
154
|
+
to: string;
|
|
155
|
+
media: CallMediaKind;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface StartCallResponse {
|
|
159
|
+
callId: string;
|
|
160
|
+
iceServers: IceServerConfig[];
|
|
161
|
+
sfuFocus?: SfuFocus | null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface IceServersResponse {
|
|
165
|
+
iceServers: IceServerConfig[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface CallSessionSummary {
|
|
169
|
+
id: string;
|
|
170
|
+
peer: string;
|
|
171
|
+
direction: CallDirection;
|
|
172
|
+
state: CallState;
|
|
173
|
+
media: CallMediaKind;
|
|
174
|
+
createdAt: string;
|
|
175
|
+
connectedAt?: string | null;
|
|
176
|
+
endedAt?: string | null;
|
|
177
|
+
endReason?: string | null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Runtime validation (used by the backend ingest to reject malformed frames)
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
const SIGNAL_TYPES: readonly RtcSignalType[] = [
|
|
185
|
+
"offer",
|
|
186
|
+
"answer",
|
|
187
|
+
"candidate",
|
|
188
|
+
"accept",
|
|
189
|
+
"reject",
|
|
190
|
+
"hangup",
|
|
191
|
+
"cancel",
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
195
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isCallMediaKind(value: unknown): value is CallMediaKind {
|
|
199
|
+
return (
|
|
200
|
+
isPlainObject(value) &&
|
|
201
|
+
typeof value.audio === "boolean" &&
|
|
202
|
+
typeof value.video === "boolean"
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseCandidates(value: unknown): CallIceCandidate[] | undefined {
|
|
207
|
+
if (value === undefined) return undefined;
|
|
208
|
+
if (!Array.isArray(value)) return undefined;
|
|
209
|
+
const out: CallIceCandidate[] = [];
|
|
210
|
+
for (const raw of value) {
|
|
211
|
+
if (!isPlainObject(raw) || typeof raw.candidate !== "string") continue;
|
|
212
|
+
out.push({
|
|
213
|
+
candidate: raw.candidate,
|
|
214
|
+
sdpMid: typeof raw.sdpMid === "string" ? raw.sdpMid : null,
|
|
215
|
+
sdpMLineIndex:
|
|
216
|
+
typeof raw.sdpMLineIndex === "number" ? raw.sdpMLineIndex : null,
|
|
217
|
+
usernameFragment:
|
|
218
|
+
typeof raw.usernameFragment === "string" ? raw.usernameFragment : null,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseSfuFocus(value: unknown): SfuFocus | null | undefined {
|
|
225
|
+
if (value === undefined) return undefined;
|
|
226
|
+
if (value === null) return null;
|
|
227
|
+
if (!isPlainObject(value)) return undefined;
|
|
228
|
+
if (typeof value.kind !== "string" || typeof value.url !== "string") {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
kind: value.kind,
|
|
233
|
+
url: value.url,
|
|
234
|
+
token: typeof value.token === "string" ? value.token : undefined,
|
|
235
|
+
room: typeof value.room === "string" ? value.room : undefined,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Parse + validate an inbound cross-instance signaling envelope. Returns the
|
|
241
|
+
* normalized envelope or `null` when the shape is invalid. Callers additionally
|
|
242
|
+
* enforce that the HTTP-Signature signer equals `from` and that the recipient
|
|
243
|
+
* (`to`) is a local actor.
|
|
244
|
+
*/
|
|
245
|
+
export function parseRtcSignalEnvelope(
|
|
246
|
+
input: unknown,
|
|
247
|
+
): RtcSignalEnvelopeV1 | null {
|
|
248
|
+
if (!isPlainObject(input)) return null;
|
|
249
|
+
if (input.v !== RTC_SIGNAL_ENVELOPE_VERSION) return null;
|
|
250
|
+
const { callId, from, to, type, ts, ttlMs } = input;
|
|
251
|
+
if (
|
|
252
|
+
typeof callId !== "string" ||
|
|
253
|
+
callId.length === 0 ||
|
|
254
|
+
callId.length > 200 ||
|
|
255
|
+
typeof from !== "string" ||
|
|
256
|
+
from.length === 0 ||
|
|
257
|
+
typeof to !== "string" ||
|
|
258
|
+
to.length === 0 ||
|
|
259
|
+
typeof type !== "string" ||
|
|
260
|
+
!SIGNAL_TYPES.includes(type as RtcSignalType) ||
|
|
261
|
+
typeof ts !== "number" ||
|
|
262
|
+
!Number.isFinite(ts) ||
|
|
263
|
+
typeof ttlMs !== "number" ||
|
|
264
|
+
!Number.isFinite(ttlMs) ||
|
|
265
|
+
ttlMs < 0
|
|
266
|
+
) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const sdp = typeof input.sdp === "string" ? input.sdp : undefined;
|
|
270
|
+
// Guard against absurd SDP blobs abusing the endpoint as a relay.
|
|
271
|
+
if (sdp !== undefined && sdp.length > 100_000) return null;
|
|
272
|
+
return {
|
|
273
|
+
v: RTC_SIGNAL_ENVELOPE_VERSION,
|
|
274
|
+
callId,
|
|
275
|
+
from,
|
|
276
|
+
to,
|
|
277
|
+
type: type as RtcSignalType,
|
|
278
|
+
media: isCallMediaKind(input.media) ? input.media : undefined,
|
|
279
|
+
sdp,
|
|
280
|
+
candidates: parseCandidates(input.candidates),
|
|
281
|
+
sfuFocus: parseSfuFocus(input.sfuFocus),
|
|
282
|
+
reason:
|
|
283
|
+
typeof input.reason === "string" ? input.reason.slice(0, 200) : undefined,
|
|
284
|
+
ts,
|
|
285
|
+
ttlMs,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** True when the envelope is still within its freshness window. */
|
|
290
|
+
export function isEnvelopeFresh(
|
|
291
|
+
envelope: RtcSignalEnvelopeV1,
|
|
292
|
+
now: number,
|
|
293
|
+
): boolean {
|
|
294
|
+
// Reject frames from the future (clock skew tolerance) or past their TTL.
|
|
295
|
+
const skewToleranceMs = 30_000;
|
|
296
|
+
if (envelope.ts - now > skewToleranceMs) return false;
|
|
297
|
+
return now - envelope.ts <= envelope.ttlMs;
|
|
298
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// Call signaling wire contract + browser<->hub frames (voice + video).
|
|
2
|
+
export * from "./call.ts";
|
|
3
|
+
|
|
4
|
+
// Realtime stream wire contract (per-user event feed + control frames).
|
|
5
|
+
export * from "./realtime.ts";
|
|
6
|
+
|
|
1
7
|
// ===== Yurucommu AP-Native Types =====
|
|
2
8
|
|
|
3
9
|
// Actor represents a user (Person) in ActivityPub
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime stream wire contract (browser <-> per-user RealtimeStreamDO).
|
|
3
|
+
*
|
|
4
|
+
* One authenticated WebSocket per user carries every live update the client
|
|
5
|
+
* used to poll for: talk messages, typing, read receipts, contact-list
|
|
6
|
+
* changes, new notifications, and the authoritative unread counters. The
|
|
7
|
+
* server pushes `RealtimeEvent` envelopes; the client sends only the small
|
|
8
|
+
* control frames below (writes stay on the REST API).
|
|
9
|
+
*
|
|
10
|
+
* Event ids are a per-user monotonic sequence assigned by the Durable Object.
|
|
11
|
+
* A reconnecting client offers its last seen id in `hello`; the DO replays the
|
|
12
|
+
* gap from its ring buffer, or answers `resync` when the gap is older than the
|
|
13
|
+
* buffer so the client re-fetches via the normal REST reads.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type RealtimeEventType =
|
|
17
|
+
| "talk.message"
|
|
18
|
+
| "talk.typing"
|
|
19
|
+
| "talk.read"
|
|
20
|
+
| "talk.contacts_changed"
|
|
21
|
+
| "notification.new"
|
|
22
|
+
| "unread";
|
|
23
|
+
|
|
24
|
+
export interface RealtimeEvent {
|
|
25
|
+
/** Per-user monotonic sequence number (assigned by the stream DO). */
|
|
26
|
+
id: number;
|
|
27
|
+
type: RealtimeEventType;
|
|
28
|
+
data: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `talk.message` payload. `other_ap_id` is from the RECEIVING user's view. */
|
|
32
|
+
export interface TalkMessageEventData {
|
|
33
|
+
kind: "dm" | "community";
|
|
34
|
+
/** DM: the counterpart actor (per-recipient). */
|
|
35
|
+
other_ap_id?: string;
|
|
36
|
+
/** Community chat: the community actor. */
|
|
37
|
+
community_ap_id?: string;
|
|
38
|
+
conversation_id?: string;
|
|
39
|
+
message: {
|
|
40
|
+
id: string;
|
|
41
|
+
sender: {
|
|
42
|
+
ap_id: string;
|
|
43
|
+
username: string;
|
|
44
|
+
preferred_username: string | null;
|
|
45
|
+
name: string | null;
|
|
46
|
+
icon_url: string | null;
|
|
47
|
+
};
|
|
48
|
+
content: string | null;
|
|
49
|
+
attachments?: unknown[];
|
|
50
|
+
created_at: string | null;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TalkTypingEventData {
|
|
55
|
+
other_ap_id: string;
|
|
56
|
+
is_typing: boolean;
|
|
57
|
+
typed_at: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TalkReadEventData {
|
|
61
|
+
other_ap_id: string;
|
|
62
|
+
conversation_id: string;
|
|
63
|
+
last_read_at: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Authoritative unread counters (server-computed; never client-derived). */
|
|
67
|
+
export interface UnreadEventData {
|
|
68
|
+
dm: number;
|
|
69
|
+
community: number;
|
|
70
|
+
talk_total: number;
|
|
71
|
+
notifications: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Client -> server frames -------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export type RealtimeClientFrame =
|
|
77
|
+
{ t: "hello"; lastEventId?: number } | { t: "ping" } | { t: "pong" };
|
|
78
|
+
|
|
79
|
+
// --- Server -> client frames -------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export type RealtimeServerFrame =
|
|
82
|
+
| { t: "hello_ok"; lastEventId: number }
|
|
83
|
+
| { t: "event"; event: RealtimeEvent }
|
|
84
|
+
/** The requested replay gap is older than the buffer: re-fetch via REST. */
|
|
85
|
+
| { t: "resync" }
|
|
86
|
+
| { t: "ping" }
|
|
87
|
+
| { t: "pong" };
|
|
88
|
+
|
|
89
|
+
export function parseRealtimeClientFrame(
|
|
90
|
+
raw: unknown,
|
|
91
|
+
): RealtimeClientFrame | null {
|
|
92
|
+
if (!raw || typeof raw !== "object") return null;
|
|
93
|
+
const frame = raw as { t?: unknown; lastEventId?: unknown };
|
|
94
|
+
if (frame.t === "ping" || frame.t === "pong") return { t: frame.t };
|
|
95
|
+
if (frame.t === "hello") {
|
|
96
|
+
const lastEventId =
|
|
97
|
+
typeof frame.lastEventId === "number" &&
|
|
98
|
+
Number.isFinite(frame.lastEventId) &&
|
|
99
|
+
frame.lastEventId >= 0
|
|
100
|
+
? Math.floor(frame.lastEventId)
|
|
101
|
+
: undefined;
|
|
102
|
+
return { t: "hello", lastEventId };
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseRealtimeServerFrame(
|
|
108
|
+
raw: unknown,
|
|
109
|
+
): RealtimeServerFrame | null {
|
|
110
|
+
if (!raw || typeof raw !== "object") return null;
|
|
111
|
+
const frame = raw as { t?: unknown; event?: unknown; lastEventId?: unknown };
|
|
112
|
+
if (frame.t === "ping" || frame.t === "pong" || frame.t === "resync") {
|
|
113
|
+
return { t: frame.t };
|
|
114
|
+
}
|
|
115
|
+
if (frame.t === "hello_ok" && typeof frame.lastEventId === "number") {
|
|
116
|
+
return { t: "hello_ok", lastEventId: frame.lastEventId };
|
|
117
|
+
}
|
|
118
|
+
if (frame.t === "event" && frame.event && typeof frame.event === "object") {
|
|
119
|
+
const event = frame.event as {
|
|
120
|
+
id?: unknown;
|
|
121
|
+
type?: unknown;
|
|
122
|
+
data?: unknown;
|
|
123
|
+
};
|
|
124
|
+
if (typeof event.id === "number" && typeof event.type === "string") {
|
|
125
|
+
return {
|
|
126
|
+
t: "event",
|
|
127
|
+
event: {
|
|
128
|
+
id: event.id,
|
|
129
|
+
type: event.type as RealtimeEventType,
|
|
130
|
+
data:
|
|
131
|
+
event.data && typeof event.data === "object"
|
|
132
|
+
? (event.data as Record<string, unknown>)
|
|
133
|
+
: {},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
package/src/backend/index.ts
CHANGED
|
@@ -29,6 +29,9 @@ 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";
|
|
33
|
+
import realtimeRoutes from "./routes/realtime/index.ts";
|
|
34
|
+
import { sweepRealtimeNotifications } from "./runtime/realtime-hub.ts";
|
|
32
35
|
|
|
33
36
|
import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
|
|
34
37
|
import { csrfProtection } from "./middleware/csrf.ts";
|
|
@@ -254,6 +257,7 @@ function buildSocialServerDiscovery(
|
|
|
254
257
|
authProviders: `${appUrl}/api/auth/providers`,
|
|
255
258
|
mobilePasswordLogin: `${appUrl}/api/auth/mobile/login`,
|
|
256
259
|
mobileOidcExchange: `${appUrl}/api/auth/mobile/oidc`,
|
|
260
|
+
mobileLogout: `${appUrl}/api/auth/logout`,
|
|
257
261
|
currentUser: `${appUrl}/api/auth/me`,
|
|
258
262
|
timeline: `${appUrl}/api/timeline`,
|
|
259
263
|
conversations: `${appUrl}/api/dm/contacts`,
|
|
@@ -506,6 +510,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
506
510
|
app.use("*", async (c, next) => {
|
|
507
511
|
await next();
|
|
508
512
|
|
|
513
|
+
// A 101 Switching Protocols response carries a WebSocket and immutable
|
|
514
|
+
// headers (the /api/rtc/socket call upgrade). Mutating it throws and breaks
|
|
515
|
+
// the upgrade, so skip the security-header pass for it.
|
|
516
|
+
if (c.res.status === 101) return;
|
|
517
|
+
|
|
509
518
|
const preserveRouteSecurityHeaders = c.req.path.startsWith("/hosted/");
|
|
510
519
|
const setSecurityHeader = (name: string, value: string) => {
|
|
511
520
|
if (preserveRouteSecurityHeaders && c.res.headers.has(name)) {
|
|
@@ -553,9 +562,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
553
562
|
setSecurityHeader("X-Content-Type-Options", "nosniff");
|
|
554
563
|
setSecurityHeader("X-Frame-Options", "DENY");
|
|
555
564
|
setSecurityHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
565
|
+
// Allow the app's OWN origin to use camera + microphone (WebRTC calls);
|
|
566
|
+
// still deny geolocation and deny camera/mic to any cross-origin frame.
|
|
556
567
|
setSecurityHeader(
|
|
557
568
|
"Permissions-Policy",
|
|
558
|
-
"camera=(), microphone=(), geolocation=()",
|
|
569
|
+
"camera=(self), microphone=(self), geolocation=()",
|
|
559
570
|
);
|
|
560
571
|
// HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
|
|
561
572
|
// (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
|
|
@@ -591,6 +602,9 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
591
602
|
error,
|
|
592
603
|
});
|
|
593
604
|
}
|
|
605
|
+
// Same choke point feeds the realtime stream: the push-jobs the inbox
|
|
606
|
+
// trigger wrote tell us exactly which users gained a notification.
|
|
607
|
+
await sweepRealtimeNotifications(c.env);
|
|
594
608
|
})();
|
|
595
609
|
// Prefer to run the sweep AFTER the response is sent (waitUntil) so its
|
|
596
610
|
// 2-4 D1 round-trips never add latency to the request. `executionCtx`
|
|
@@ -693,6 +707,11 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
693
707
|
app.use(pattern, rateLimit(RateLimitConfigs.inbox));
|
|
694
708
|
}
|
|
695
709
|
|
|
710
|
+
// Cross-instance call signaling ingest is unauthenticated at the network edge
|
|
711
|
+
// (it verifies an HTTP Signature inside the handler) and can be hit by any
|
|
712
|
+
// remote instance, so throttle it per-IP like the other federation endpoints.
|
|
713
|
+
app.use("/ap/rtc/signal", rateLimit(RateLimitConfigs.federationDiscovery));
|
|
714
|
+
|
|
696
715
|
// Federation discovery endpoints are unauthenticated and can be probed by
|
|
697
716
|
// any remote actor. Throttle them per-IP to mitigate enumeration / DoS.
|
|
698
717
|
app.use(
|
|
@@ -766,6 +785,10 @@ function mountCoreRoutes(app: YurucommuApp): void {
|
|
|
766
785
|
app.route("/api/moderation", moderationRoutes);
|
|
767
786
|
app.route("/api/apps", appsApiRoutes);
|
|
768
787
|
app.route("/hosted", appsServeRoutes);
|
|
788
|
+
// Realtime stream: capability probe + WS ticket + per-user socket upgrade.
|
|
789
|
+
app.route("/api/realtime", realtimeRoutes);
|
|
790
|
+
// Call feature: /api/rtc/* (session) + /ap/rtc/signal (server-to-server).
|
|
791
|
+
app.route("/", rtcRoutes);
|
|
769
792
|
app.route("/", activitypubRoutes);
|
|
770
793
|
}
|
|
771
794
|
|
|
@@ -933,6 +956,14 @@ type WorkerBindings = EnvVars & {
|
|
|
933
956
|
ASSETS?: Fetcher;
|
|
934
957
|
DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
|
|
935
958
|
DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
|
|
959
|
+
// Signaling hub Durable Object namespace (call feature). wrapCloudflareBindings
|
|
960
|
+
// spreads it through untouched (it is not DB/MEDIA/KV/ASSETS) so app code and
|
|
961
|
+
// the rtc routes read it as c.env.CALL_SIGNALING.
|
|
962
|
+
CALL_SIGNALING?: DurableObjectNamespace;
|
|
963
|
+
// Per-user realtime event stream Durable Object namespace. Same pass-through
|
|
964
|
+
// as CALL_SIGNALING; optional — when unbound the realtime routes answer 503
|
|
965
|
+
// and clients fall back to polling.
|
|
966
|
+
REALTIME_STREAM?: DurableObjectNamespace;
|
|
936
967
|
};
|
|
937
968
|
|
|
938
969
|
export default {
|
|
@@ -531,6 +531,10 @@ export async function handleDeliveryQueueBatch(
|
|
|
531
531
|
error,
|
|
532
532
|
});
|
|
533
533
|
}
|
|
534
|
+
// Same choke point feeds the realtime stream (federated + fanout inserts).
|
|
535
|
+
const { sweepRealtimeNotifications } =
|
|
536
|
+
await import("../../runtime/realtime-hub.ts");
|
|
537
|
+
await sweepRealtimeNotifications(env);
|
|
534
538
|
}
|
|
535
539
|
|
|
536
540
|
export async function handleDeliveryDlqBatch(
|
|
@@ -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
|
+
}
|