@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.
Files changed (33) hide show
  1. package/migrations/0020_call_sessions.sql +23 -0
  2. package/package.json +2 -1
  3. package/packages/api/package.json +1 -1
  4. package/packages/api/src/index.ts +2 -0
  5. package/packages/api/src/lib/rtc-client.ts +540 -0
  6. package/packages/api/src/types/call.ts +298 -0
  7. package/packages/api/src/types/index.ts +6 -0
  8. package/packages/api/src/types/realtime.ts +139 -0
  9. package/src/backend/index.ts +32 -1
  10. package/src/backend/lib/delivery/queue.ts +4 -0
  11. package/src/backend/lib/rtc/call-store.ts +101 -0
  12. package/src/backend/lib/rtc/provider.ts +135 -0
  13. package/src/backend/lib/rtc/signal-transport.ts +125 -0
  14. package/src/backend/lib/unread-counts.ts +63 -2
  15. package/src/backend/public.ts +6 -0
  16. package/src/backend/routes/activitypub.ts +5 -1
  17. package/src/backend/routes/auth.ts +8 -2
  18. package/src/backend/routes/communities/messages.ts +53 -17
  19. package/src/backend/routes/dm/messages.ts +47 -7
  20. package/src/backend/routes/dm/read-archive.ts +27 -0
  21. package/src/backend/routes/dm/typing.ts +16 -0
  22. package/src/backend/routes/notifications.ts +25 -0
  23. package/src/backend/routes/realtime/index.ts +67 -0
  24. package/src/backend/routes/rtc/index.ts +151 -0
  25. package/src/backend/runtime/call-hub-core.ts +480 -0
  26. package/src/backend/runtime/call-hub-port.ts +78 -0
  27. package/src/backend/runtime/call-signaling-do.ts +242 -0
  28. package/src/backend/runtime/realtime-hub.ts +257 -0
  29. package/src/backend/runtime/realtime-stream-do.ts +323 -0
  30. package/src/backend/runtime/signaling-hub.ts +187 -0
  31. package/src/backend/types.ts +32 -0
  32. package/src/db/schema/calls.ts +46 -0
  33. 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
+ }
@@ -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
+ }