@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,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RealtimeStreamDO — per-local-user realtime fanout stream.
|
|
3
|
+
*
|
|
4
|
+
* One DO instance per local actor (`idFromName(actorApId)`). It is the single
|
|
5
|
+
* standing WebSocket the browser keeps open; every live update the client used
|
|
6
|
+
* to poll for (talk messages, typing, read receipts, notifications, unread
|
|
7
|
+
* counters) is pushed through it as a `RealtimeEvent`.
|
|
8
|
+
*
|
|
9
|
+
* Producers (the worker's REST handlers and queue consumers) POST events to
|
|
10
|
+
* `/_emit`; the DO assigns a monotonic id, persists the event into a small
|
|
11
|
+
* ring buffer (so a reconnect can replay the gap across hibernation), and
|
|
12
|
+
* broadcasts to every connected socket. Deliberately separate from
|
|
13
|
+
* `CallSignalingDurableObject`: call signaling is ephemeral SDP/ICE with its
|
|
14
|
+
* own state machine, while this stream is a durable-ordered event feed.
|
|
15
|
+
*
|
|
16
|
+
* Auth model: the DO binding is the trust boundary. `/ _ws` upgrades arrive
|
|
17
|
+
* only via the worker route, which either resolved the session actor or
|
|
18
|
+
* verified a one-time ticket this DO minted earlier (`/_ticket`); the DO
|
|
19
|
+
* re-checks ticket upgrades against its own storage so a ticket is
|
|
20
|
+
* single-use and expires even if the worker is confused.
|
|
21
|
+
*
|
|
22
|
+
* Uses Hibernatable WebSockets: idle sockets are evicted from memory and the
|
|
23
|
+
* ring buffer lives in DO storage, so an idle connected user costs nothing.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
RealtimeEvent,
|
|
28
|
+
RealtimeServerFrame,
|
|
29
|
+
} from "../../../packages/api/src/types/realtime.ts";
|
|
30
|
+
import { parseRealtimeClientFrame } from "../../../packages/api/src/types/realtime.ts";
|
|
31
|
+
|
|
32
|
+
// --- Minimal Cloudflare DO + Hibernatable WebSocket surface ----------------
|
|
33
|
+
// (typed file-locally, matching call-signaling-do.ts, so this file does not
|
|
34
|
+
// depend on a specific @cloudflare/workers-types version)
|
|
35
|
+
interface DoWebSocket {
|
|
36
|
+
send(data: string): void;
|
|
37
|
+
close(code?: number, reason?: string): void;
|
|
38
|
+
}
|
|
39
|
+
interface DoStorage {
|
|
40
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
41
|
+
put(key: string, value: unknown): Promise<void>;
|
|
42
|
+
delete(key: string): Promise<boolean>;
|
|
43
|
+
list<T = unknown>(options?: {
|
|
44
|
+
prefix?: string;
|
|
45
|
+
limit?: number;
|
|
46
|
+
reverse?: boolean;
|
|
47
|
+
}): Promise<Map<string, T>>;
|
|
48
|
+
}
|
|
49
|
+
interface DoState {
|
|
50
|
+
acceptWebSocket(ws: DoWebSocket, tags?: string[]): void;
|
|
51
|
+
getWebSockets(tag?: string): DoWebSocket[];
|
|
52
|
+
readonly storage: DoStorage;
|
|
53
|
+
}
|
|
54
|
+
declare const WebSocketPair: {
|
|
55
|
+
new (): { 0: DoWebSocket; 1: DoWebSocket };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const SEQ_KEY = "seq";
|
|
59
|
+
const EVENT_PREFIX = "evt:";
|
|
60
|
+
/** Ring buffer size: how many events a reconnect can replay before `resync`. */
|
|
61
|
+
const EVENT_BUFFER_SIZE = 200;
|
|
62
|
+
const TICKET_PREFIX = "ticket:";
|
|
63
|
+
/** Outstanding one-time tickets per user (multiple tabs may mint at once). */
|
|
64
|
+
const MAX_OUTSTANDING_TICKETS = 8;
|
|
65
|
+
const TICKET_TTL_MS = 60_000;
|
|
66
|
+
|
|
67
|
+
interface StoredTicket {
|
|
68
|
+
hash: string;
|
|
69
|
+
expiresAt: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function eventKey(seq: number): string {
|
|
73
|
+
// Fixed-width key so storage.list({prefix}) returns events in seq order.
|
|
74
|
+
return `${EVENT_PREFIX}${String(seq).padStart(12, "0")}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
78
|
+
const digest = await crypto.subtle.digest(
|
|
79
|
+
"SHA-256",
|
|
80
|
+
new TextEncoder().encode(value),
|
|
81
|
+
);
|
|
82
|
+
return [...new Uint8Array(digest)]
|
|
83
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
84
|
+
.join("");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Constant-time hex-string comparison (both inputs are fixed-width hashes). */
|
|
88
|
+
function timingSafeEqualHex(a: string, b: string): boolean {
|
|
89
|
+
if (a.length !== b.length) return false;
|
|
90
|
+
let diff = 0;
|
|
91
|
+
for (let i = 0; i < a.length; i++) {
|
|
92
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
93
|
+
}
|
|
94
|
+
return diff === 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class RealtimeStreamDO {
|
|
98
|
+
private seqCache: number | null = null;
|
|
99
|
+
|
|
100
|
+
constructor(private readonly state: DoState) {}
|
|
101
|
+
|
|
102
|
+
async fetch(request: Request): Promise<Response> {
|
|
103
|
+
const url = new URL(request.url);
|
|
104
|
+
switch (url.pathname) {
|
|
105
|
+
case "/_ws":
|
|
106
|
+
return this.handleUpgrade(request);
|
|
107
|
+
case "/_emit":
|
|
108
|
+
return this.handleEmit(request);
|
|
109
|
+
case "/_ticket":
|
|
110
|
+
return this.handleMintTicket(request);
|
|
111
|
+
case "/_state":
|
|
112
|
+
return this.handleState();
|
|
113
|
+
default:
|
|
114
|
+
return new Response("not found", { status: 404 });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
// Ticket mint + verify (one-time, short-lived; stored only as a hash)
|
|
120
|
+
// -------------------------------------------------------------------------
|
|
121
|
+
private async handleMintTicket(request: Request): Promise<Response> {
|
|
122
|
+
if (request.method !== "POST") {
|
|
123
|
+
return new Response("method not allowed", { status: 405 });
|
|
124
|
+
}
|
|
125
|
+
const ticket =
|
|
126
|
+
crypto.randomUUID().replaceAll("-", "") +
|
|
127
|
+
crypto.randomUUID().replaceAll("-", "");
|
|
128
|
+
const hash = await sha256Hex(ticket);
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
|
|
131
|
+
const stored = await this.state.storage.list<StoredTicket>({
|
|
132
|
+
prefix: TICKET_PREFIX,
|
|
133
|
+
});
|
|
134
|
+
// Drop expired tickets; keep the newest few so parallel tabs still work.
|
|
135
|
+
const live = [...stored.entries()]
|
|
136
|
+
.filter(([, t]) => t.expiresAt > now)
|
|
137
|
+
.sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
|
138
|
+
for (const [key] of stored) {
|
|
139
|
+
if (!live.some(([liveKey]) => liveKey === key)) {
|
|
140
|
+
await this.state.storage.delete(key);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
while (live.length >= MAX_OUTSTANDING_TICKETS) {
|
|
144
|
+
const [oldestKey] = live.shift()!;
|
|
145
|
+
await this.state.storage.delete(oldestKey);
|
|
146
|
+
}
|
|
147
|
+
await this.state.storage.put(`${TICKET_PREFIX}${hash}`, {
|
|
148
|
+
hash,
|
|
149
|
+
expiresAt: now + TICKET_TTL_MS,
|
|
150
|
+
} satisfies StoredTicket);
|
|
151
|
+
|
|
152
|
+
return Response.json({ ticket });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async consumeTicket(ticket: string): Promise<boolean> {
|
|
156
|
+
const hash = await sha256Hex(ticket);
|
|
157
|
+
const key = `${TICKET_PREFIX}${hash}`;
|
|
158
|
+
const stored = await this.state.storage.get<StoredTicket>(key);
|
|
159
|
+
if (!stored) return false;
|
|
160
|
+
// Single-use: consume before validating expiry so a replay always misses.
|
|
161
|
+
await this.state.storage.delete(key);
|
|
162
|
+
if (stored.expiresAt <= Date.now()) return false;
|
|
163
|
+
return timingSafeEqualHex(stored.hash, hash);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// -------------------------------------------------------------------------
|
|
167
|
+
// WebSocket upgrade
|
|
168
|
+
// -------------------------------------------------------------------------
|
|
169
|
+
private async handleUpgrade(request: Request): Promise<Response> {
|
|
170
|
+
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
|
171
|
+
return new Response("expected websocket", { status: 426 });
|
|
172
|
+
}
|
|
173
|
+
// The worker route either authenticated the session itself (auth=session)
|
|
174
|
+
// or forwards a ticket this DO minted; re-verify tickets against storage.
|
|
175
|
+
const authMode = request.headers.get("X-Realtime-Auth");
|
|
176
|
+
if (authMode === "ticket") {
|
|
177
|
+
const ticket = request.headers.get("X-Realtime-Ticket") ?? "";
|
|
178
|
+
if (!ticket || !(await this.consumeTicket(ticket))) {
|
|
179
|
+
return new Response("invalid ticket", { status: 401 });
|
|
180
|
+
}
|
|
181
|
+
} else if (authMode !== "session") {
|
|
182
|
+
return new Response("unauthorized", { status: 401 });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const pair = new WebSocketPair();
|
|
186
|
+
const client = pair[0];
|
|
187
|
+
const server = pair[1];
|
|
188
|
+
this.state.acceptWebSocket(server);
|
|
189
|
+
return new Response(null, {
|
|
190
|
+
status: 101,
|
|
191
|
+
// `webSocket` is a Cloudflare-specific ResponseInit field.
|
|
192
|
+
webSocket: client,
|
|
193
|
+
} as unknown as ResponseInit);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// -------------------------------------------------------------------------
|
|
197
|
+
// Event ingest + fanout
|
|
198
|
+
// -------------------------------------------------------------------------
|
|
199
|
+
private async handleEmit(request: Request): Promise<Response> {
|
|
200
|
+
if (request.method !== "POST") {
|
|
201
|
+
return new Response("method not allowed", { status: 405 });
|
|
202
|
+
}
|
|
203
|
+
let body: { type?: unknown; data?: unknown };
|
|
204
|
+
try {
|
|
205
|
+
body = (await request.json()) as { type?: unknown; data?: unknown };
|
|
206
|
+
} catch {
|
|
207
|
+
return new Response("bad json", { status: 400 });
|
|
208
|
+
}
|
|
209
|
+
if (typeof body.type !== "string" || !body.type) {
|
|
210
|
+
return new Response("bad event", { status: 400 });
|
|
211
|
+
}
|
|
212
|
+
const data =
|
|
213
|
+
body.data && typeof body.data === "object"
|
|
214
|
+
? (body.data as Record<string, unknown>)
|
|
215
|
+
: {};
|
|
216
|
+
|
|
217
|
+
const seq = (await this.currentSeq()) + 1;
|
|
218
|
+
const event: RealtimeEvent = {
|
|
219
|
+
id: seq,
|
|
220
|
+
type: body.type as RealtimeEvent["type"],
|
|
221
|
+
data,
|
|
222
|
+
};
|
|
223
|
+
await this.state.storage.put(eventKey(seq), event);
|
|
224
|
+
await this.state.storage.put(SEQ_KEY, seq);
|
|
225
|
+
this.seqCache = seq;
|
|
226
|
+
const pruneSeq = seq - EVENT_BUFFER_SIZE;
|
|
227
|
+
if (pruneSeq > 0) {
|
|
228
|
+
await this.state.storage.delete(eventKey(pruneSeq));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.broadcast({ t: "event", event });
|
|
232
|
+
return Response.json({
|
|
233
|
+
id: seq,
|
|
234
|
+
sockets: this.state.getWebSockets().length,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async handleState(): Promise<Response> {
|
|
239
|
+
return Response.json({
|
|
240
|
+
seq: await this.currentSeq(),
|
|
241
|
+
sockets: this.state.getWebSockets().length,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// -------------------------------------------------------------------------
|
|
246
|
+
// Hibernatable WebSocket events
|
|
247
|
+
// -------------------------------------------------------------------------
|
|
248
|
+
async webSocketMessage(
|
|
249
|
+
ws: DoWebSocket,
|
|
250
|
+
message: string | ArrayBuffer,
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
if (typeof message !== "string") return;
|
|
253
|
+
let parsed: unknown;
|
|
254
|
+
try {
|
|
255
|
+
parsed = JSON.parse(message);
|
|
256
|
+
} catch {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const frame = parseRealtimeClientFrame(parsed);
|
|
260
|
+
if (!frame) return;
|
|
261
|
+
|
|
262
|
+
switch (frame.t) {
|
|
263
|
+
case "ping":
|
|
264
|
+
this.send(ws, { t: "pong" });
|
|
265
|
+
return;
|
|
266
|
+
case "pong":
|
|
267
|
+
return;
|
|
268
|
+
case "hello": {
|
|
269
|
+
const seq = await this.currentSeq();
|
|
270
|
+
if (frame.lastEventId !== undefined && frame.lastEventId < seq) {
|
|
271
|
+
const oldestBuffered = Math.max(1, seq - EVENT_BUFFER_SIZE + 1);
|
|
272
|
+
if (frame.lastEventId >= oldestBuffered - 1) {
|
|
273
|
+
for (let i = frame.lastEventId + 1; i <= seq; i++) {
|
|
274
|
+
const event = await this.state.storage.get<RealtimeEvent>(
|
|
275
|
+
eventKey(i),
|
|
276
|
+
);
|
|
277
|
+
if (event) this.send(ws, { t: "event", event });
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
// Gap predates the ring buffer: the client must re-fetch via REST.
|
|
281
|
+
this.send(ws, { t: "resync" });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
this.send(ws, { t: "hello_ok", lastEventId: seq });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async webSocketClose(ws: DoWebSocket): Promise<void> {
|
|
291
|
+
try {
|
|
292
|
+
ws.close();
|
|
293
|
+
} catch {
|
|
294
|
+
// already closing
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async webSocketError(): Promise<void> {
|
|
299
|
+
// getWebSockets() excludes the errored socket automatically.
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// -------------------------------------------------------------------------
|
|
303
|
+
// Internals
|
|
304
|
+
// -------------------------------------------------------------------------
|
|
305
|
+
private async currentSeq(): Promise<number> {
|
|
306
|
+
if (this.seqCache !== null) return this.seqCache;
|
|
307
|
+
const stored = await this.state.storage.get<number>(SEQ_KEY);
|
|
308
|
+
this.seqCache = typeof stored === "number" ? stored : 0;
|
|
309
|
+
return this.seqCache;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private broadcast(frame: RealtimeServerFrame): void {
|
|
313
|
+
for (const ws of this.state.getWebSockets()) this.send(ws, frame);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private send(ws: DoWebSocket, frame: RealtimeServerFrame): void {
|
|
317
|
+
try {
|
|
318
|
+
ws.send(JSON.stringify(frame));
|
|
319
|
+
} catch {
|
|
320
|
+
// socket gone; getWebSockets() will drop it
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISignalingHub — the seam that decouples call signaling from the Durable Object
|
|
3
|
+
* runtime. The `/api/rtc/socket` and `/ap/rtc/signal` routes talk to this
|
|
4
|
+
* interface; on Cloudflare it forwards to the per-user `CallSignalingDurable
|
|
5
|
+
* Object`, and on a Bun/Node self-host it uses an in-process hub.
|
|
6
|
+
*
|
|
7
|
+
* The DO instance itself is addressed by `idFromName(actorApId)`, so signaling
|
|
8
|
+
* for a given local user always lands on the same object regardless of which
|
|
9
|
+
* edge handled the request.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Env } from "../types.ts";
|
|
13
|
+
import type { RtcSignalEnvelopeV1 } from "../../../packages/api/src/types/call.ts";
|
|
14
|
+
import type {
|
|
15
|
+
ClientToHubFrame,
|
|
16
|
+
HubToClientFrame,
|
|
17
|
+
} from "../../../packages/api/src/types/call.ts";
|
|
18
|
+
import { CallHub, type HubConnection } from "./call-hub-core.ts";
|
|
19
|
+
import { createCallHubPort } from "./call-hub-port.ts";
|
|
20
|
+
import { logger } from "../lib/logger.ts";
|
|
21
|
+
|
|
22
|
+
const log = logger.child({ component: "rtc.hub" });
|
|
23
|
+
|
|
24
|
+
export interface ISignalingHub {
|
|
25
|
+
/** Handle a browser WebSocket upgrade for `actorApId` (returns 101). */
|
|
26
|
+
upgrade(request: Request, actorApId: string): Promise<Response>;
|
|
27
|
+
/** Push an inbound cross-instance signal to `actorApId`'s live sockets. */
|
|
28
|
+
deliver(actorApId: string, envelope: RtcSignalEnvelopeV1): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Cloudflare: forward to the per-user Durable Object
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
class CloudflareSignalingHub implements ISignalingHub {
|
|
35
|
+
constructor(private readonly ns: DurableObjectNamespace) {}
|
|
36
|
+
|
|
37
|
+
private stub(actorApId: string): DurableObjectStub {
|
|
38
|
+
return this.ns.get(this.ns.idFromName(actorApId));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async upgrade(request: Request, actorApId: string): Promise<Response> {
|
|
42
|
+
const headers = new Headers(request.headers);
|
|
43
|
+
headers.set("X-Call-Actor", actorApId);
|
|
44
|
+
const forwarded = new Request("https://call-do/_ws", {
|
|
45
|
+
method: "GET",
|
|
46
|
+
headers,
|
|
47
|
+
});
|
|
48
|
+
return this.stub(actorApId).fetch(
|
|
49
|
+
forwarded as unknown as Parameters<DurableObjectStub["fetch"]>[0],
|
|
50
|
+
) as unknown as Promise<Response>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async deliver(
|
|
54
|
+
actorApId: string,
|
|
55
|
+
envelope: RtcSignalEnvelopeV1,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
await this.stub(actorApId).fetch("https://call-do/_ingest", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "Content-Type": "application/json" },
|
|
60
|
+
body: JSON.stringify(envelope),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// In-process (Bun/Node self-host): a per-actor CallHub + live socket set.
|
|
67
|
+
// The Bun server WebSocket wiring drives attach()/message()/detach(); the DO-
|
|
68
|
+
// less runtime therefore keeps calls working without Cloudflare.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
export interface LocalSocket {
|
|
71
|
+
send(data: string): void;
|
|
72
|
+
close(code?: number, reason?: string): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface LocalUserHub {
|
|
76
|
+
hub: CallHub;
|
|
77
|
+
sockets: Set<LocalSocket>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class LocalSignalingHub implements ISignalingHub {
|
|
81
|
+
private readonly users = new Map<string, LocalUserHub>();
|
|
82
|
+
|
|
83
|
+
constructor(private readonly env: Env) {}
|
|
84
|
+
|
|
85
|
+
private getOrCreate(actorApId: string): LocalUserHub {
|
|
86
|
+
let entry = this.users.get(actorApId);
|
|
87
|
+
if (entry) return entry;
|
|
88
|
+
const sockets = new Set<LocalSocket>();
|
|
89
|
+
const port = createCallHubPort({
|
|
90
|
+
localActorApId: actorApId,
|
|
91
|
+
db: this.env.DB_INSTANCE,
|
|
92
|
+
env: this.env,
|
|
93
|
+
broadcast: (frame: HubToClientFrame) => {
|
|
94
|
+
const data = JSON.stringify(frame);
|
|
95
|
+
for (const s of sockets) {
|
|
96
|
+
try {
|
|
97
|
+
s.send(data);
|
|
98
|
+
} catch {
|
|
99
|
+
// drop dead socket on next detach
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
hasClients: () => sockets.size > 0,
|
|
104
|
+
});
|
|
105
|
+
entry = { hub: new CallHub(port), sockets };
|
|
106
|
+
this.users.set(actorApId, entry);
|
|
107
|
+
return entry;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private wrap(socket: LocalSocket): HubConnection {
|
|
111
|
+
return {
|
|
112
|
+
send: (frame) => socket.send(JSON.stringify(frame)),
|
|
113
|
+
close: (code, reason) => socket.close(code, reason),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Register a live browser socket (called by the Bun server WS handler). */
|
|
118
|
+
attach(actorApId: string, socket: LocalSocket): void {
|
|
119
|
+
this.getOrCreate(actorApId).sockets.add(socket);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
detach(actorApId: string, socket: LocalSocket): void {
|
|
123
|
+
this.users.get(actorApId)?.sockets.delete(socket);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Route a browser frame (called by the Bun server WS message handler). */
|
|
127
|
+
async message(
|
|
128
|
+
actorApId: string,
|
|
129
|
+
socket: LocalSocket,
|
|
130
|
+
raw: string,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
let frame: ClientToHubFrame;
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(raw);
|
|
135
|
+
if (!parsed || typeof parsed.t !== "string") return;
|
|
136
|
+
frame = parsed as ClientToHubFrame;
|
|
137
|
+
} catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await this.getOrCreate(actorApId).hub.handleClientFrame(
|
|
141
|
+
this.wrap(socket),
|
|
142
|
+
frame,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async upgrade(_request: Request, _actorApId: string): Promise<Response> {
|
|
147
|
+
// The Bun runtime upgrades WebSockets at the server boundary (server.upgrade)
|
|
148
|
+
// and drives attach()/message()/detach() directly, so this Hono-level path is
|
|
149
|
+
// never used there. Reaching it means a runtime without Durable Objects and
|
|
150
|
+
// without the Bun WS wiring.
|
|
151
|
+
return new Response(
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
error: "signaling_unavailable",
|
|
154
|
+
message: "Call signaling requires the Durable Objects runtime.",
|
|
155
|
+
}),
|
|
156
|
+
{ status: 503, headers: { "Content-Type": "application/json" } },
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async deliver(
|
|
161
|
+
actorApId: string,
|
|
162
|
+
envelope: RtcSignalEnvelopeV1,
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
await this.getOrCreate(actorApId).hub.handleInboundSignal(envelope);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// One in-process hub per worker process (Bun/Node path only).
|
|
169
|
+
let localHubSingleton: LocalSignalingHub | null = null;
|
|
170
|
+
|
|
171
|
+
/** Resolve the signaling hub for this runtime. */
|
|
172
|
+
export function getSignalingHub(env: Env): ISignalingHub {
|
|
173
|
+
if (env.CALL_SIGNALING) {
|
|
174
|
+
return new CloudflareSignalingHub(env.CALL_SIGNALING);
|
|
175
|
+
}
|
|
176
|
+
if (!localHubSingleton) localHubSingleton = new LocalSignalingHub(env);
|
|
177
|
+
return localHubSingleton;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Whether calls can be served on this runtime (a signaling transport exists). */
|
|
181
|
+
export function isSignalingAvailable(env: Env): boolean {
|
|
182
|
+
// Cloudflare DO binding is the supported production transport. (The in-process
|
|
183
|
+
// Bun hub exists but its browser WS wiring is host-server-driven.)
|
|
184
|
+
return Boolean(env.CALL_SIGNALING);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export { log as signalingLog };
|
package/src/backend/types.ts
CHANGED
|
@@ -85,6 +85,30 @@ export interface EnvVars {
|
|
|
85
85
|
// 既存動作と同じ (= APP_URL 単一 origin のみ accept、 production 影響ゼロ)。
|
|
86
86
|
CSRF_ALLOWED_ORIGINS?: string;
|
|
87
87
|
|
|
88
|
+
// --- Call feature (WebRTC voice + video) -------------------------------
|
|
89
|
+
// ICE (STUN/TURN) servers advertised to authenticated call clients as a JSON
|
|
90
|
+
// array of { urls, username?, credential? }. When TURN uses coturn's REST
|
|
91
|
+
// ephemeral-credential scheme instead, set YURUCOMMU_RTC_TURN_URIS +
|
|
92
|
+
// YURUCOMMU_RTC_TURN_SECRET and the app mints short-lived creds per request.
|
|
93
|
+
// Unset => STUN-only (P2P still works on permissive networks; TURN is the
|
|
94
|
+
// single biggest determinant of cross-network 1:1 success, so operators
|
|
95
|
+
// should configure it). None of this is required for the worker to boot.
|
|
96
|
+
YURUCOMMU_RTC_ICE_SERVERS?: string;
|
|
97
|
+
// coturn REST-API ephemeral credentials (RFC 8489 long-term-cred via HMAC).
|
|
98
|
+
// Comma-separated turn:/turns: URIs + a shared secret; TTL in seconds.
|
|
99
|
+
YURUCOMMU_RTC_TURN_URIS?: string;
|
|
100
|
+
YURUCOMMU_RTC_TURN_SECRET?: string; // secret
|
|
101
|
+
YURUCOMMU_RTC_TURN_TTL?: string;
|
|
102
|
+
// SFU adapter selector for GROUP calls. "p2p" (default) = no SFU, 1:1 P2P
|
|
103
|
+
// only. Other values ("whip" / "livekit" / "cloudflare-realtime") select a
|
|
104
|
+
// WHIP/WHEP-speaking focus so the SFU backend stays vendor-neutral. 1:1 calls
|
|
105
|
+
// never require any SFU config.
|
|
106
|
+
YURUCOMMU_RTC_SFU_ADAPTER?: string;
|
|
107
|
+
YURUCOMMU_RTC_SFU_URL?: string;
|
|
108
|
+
YURUCOMMU_RTC_SFU_TOKEN?: string; // secret
|
|
109
|
+
YURUCOMMU_RTC_SFU_APP_ID?: string;
|
|
110
|
+
YURUCOMMU_RTC_SFU_APP_SECRET?: string; // secret
|
|
111
|
+
|
|
88
112
|
// Declare the reverse-proxy type so the client-IP resolver trusts the right
|
|
89
113
|
// forwarding header (opt-in; a worker fronted directly by a client cannot
|
|
90
114
|
// spoof its own IP otherwise). Accepted values:
|
|
@@ -119,6 +143,14 @@ export type Env = {
|
|
|
119
143
|
ASSETS?: IStaticAssets;
|
|
120
144
|
DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
|
|
121
145
|
DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
|
|
146
|
+
// Signaling hub for the call feature. Passes through wrapCloudflareBindings
|
|
147
|
+
// untouched (it is not one of DB/MEDIA/KV/ASSETS). Optional: when unbound the
|
|
148
|
+
// call routes 503 and the rest of the app serves normally.
|
|
149
|
+
CALL_SIGNALING?: DurableObjectNamespace;
|
|
150
|
+
// Per-user realtime event stream (talk/typing/read/notification/unread push).
|
|
151
|
+
// Same pass-through; optional: when unbound the realtime routes answer 503
|
|
152
|
+
// and clients fall back to their low-frequency polling loops.
|
|
153
|
+
REALTIME_STREAM?: DurableObjectNamespace;
|
|
122
154
|
} & EnvVars;
|
|
123
155
|
|
|
124
156
|
export type Variables = {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call sessions (WebRTC voice + video).
|
|
3
|
+
*
|
|
4
|
+
* One row per call, owned by the LOCAL actor. Ephemeral signaling (SDP/ICE)
|
|
5
|
+
* never lands here — it flows over the dedicated `/ap/rtc/signal` transport and
|
|
6
|
+
* the Signaling Durable Object. This table is the durable record: call history,
|
|
7
|
+
* missed-call surfacing, and current-state lookups.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
11
|
+
import { nowIso } from "./date-utils.ts";
|
|
12
|
+
import { actors } from "./actors.ts";
|
|
13
|
+
|
|
14
|
+
export const callSessions = sqliteTable(
|
|
15
|
+
"call_sessions",
|
|
16
|
+
{
|
|
17
|
+
// callId (client-minted uuid; also the signaling anti-replay nonce).
|
|
18
|
+
id: text("id").primaryKey(),
|
|
19
|
+
localActorApId: text("local_actor_ap_id")
|
|
20
|
+
.notNull()
|
|
21
|
+
.references(() => actors.apId, { onDelete: "cascade" }),
|
|
22
|
+
peerActorApId: text("peer_actor_ap_id").notNull(),
|
|
23
|
+
direction: text("direction").notNull(), // "incoming" | "outgoing"
|
|
24
|
+
// CallState: ringing | connecting | connected | ended | missed | rejected |
|
|
25
|
+
// failed | cancelled.
|
|
26
|
+
state: text("state").notNull().default("ringing"),
|
|
27
|
+
mediaAudio: integer("media_audio").notNull().default(1),
|
|
28
|
+
mediaVideo: integer("media_video").notNull().default(0),
|
|
29
|
+
// Selected SFU focus JSON, or NULL for pure P2P (1:1).
|
|
30
|
+
sfuFocus: text("sfu_focus"),
|
|
31
|
+
// Cached peer signaling endpoint so mid-call frames skip re-resolution.
|
|
32
|
+
peerSignalEndpoint: text("peer_signal_endpoint"),
|
|
33
|
+
endReason: text("end_reason"),
|
|
34
|
+
createdAt: text("created_at").notNull().$defaultFn(nowIso),
|
|
35
|
+
updatedAt: text("updated_at")
|
|
36
|
+
.notNull()
|
|
37
|
+
.$defaultFn(nowIso)
|
|
38
|
+
.$onUpdateFn(nowIso),
|
|
39
|
+
connectedAt: text("connected_at"),
|
|
40
|
+
endedAt: text("ended_at"),
|
|
41
|
+
},
|
|
42
|
+
(t) => [
|
|
43
|
+
index("call_sessions_local_created_idx").on(t.localActorApId, t.createdAt),
|
|
44
|
+
index("call_sessions_state_idx").on(t.state),
|
|
45
|
+
],
|
|
46
|
+
);
|