@takosjp/yurucommu-core 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * CallSignalingDurableObject — per-local-user signaling hub (call feature).
3
+ *
4
+ * One DO instance per local actor (`idFromName(actorApId)`). It is the standing
5
+ * presence socket the browser connects to (so an incoming ring can arrive before
6
+ * any call object exists), the fan-in point for cross-instance signals delivered
7
+ * to `/ap/rtc/signal`, and the owner of the per-call state machine (via the
8
+ * runtime-neutral `CallHub`).
9
+ *
10
+ * Uses Hibernatable WebSockets: idle presence sockets can be evicted and the DO
11
+ * reconstructs its `CallHub` from durable storage (`call:*` records) on wake.
12
+ * The CF/DO + Hibernatable-WebSocket surface is typed file-locally so this file
13
+ * does not depend on a specific `@cloudflare/workers-types` version.
14
+ */
15
+
16
+ import { getDb } from "../../db/index.ts";
17
+ import type { EnvVars } from "../types.ts";
18
+ import { CallHub, type CallRecord } from "./call-hub-core.ts";
19
+ import { createCallHubPort } from "./call-hub-port.ts";
20
+ import type {
21
+ ClientToHubFrame,
22
+ HubToClientFrame,
23
+ RtcSignalEnvelopeV1,
24
+ } from "../../../packages/api/src/types/call.ts";
25
+ import {
26
+ isTerminalCallState,
27
+ parseRtcSignalEnvelope,
28
+ } from "../../../packages/api/src/types/call.ts";
29
+
30
+ // --- Minimal Cloudflare DO + Hibernatable WebSocket surface ----------------
31
+ interface DoWebSocket {
32
+ send(data: string): void;
33
+ close(code?: number, reason?: string): void;
34
+ }
35
+ interface DoStorage {
36
+ get<T = unknown>(key: string): Promise<T | undefined>;
37
+ put(key: string, value: unknown): Promise<void>;
38
+ delete(key: string): Promise<boolean>;
39
+ list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
40
+ setAlarm(scheduledTime: number): Promise<void>;
41
+ getAlarm(): Promise<number | null>;
42
+ }
43
+ interface DoState {
44
+ acceptWebSocket(ws: DoWebSocket, tags?: string[]): void;
45
+ getWebSockets(tag?: string): DoWebSocket[];
46
+ readonly storage: DoStorage;
47
+ }
48
+ declare const WebSocketPair: {
49
+ new (): { 0: DoWebSocket; 1: DoWebSocket };
50
+ };
51
+
52
+ type CallDoEnv = EnvVars & {
53
+ DB: D1Database;
54
+ CALL_SIGNALING?: DurableObjectNamespace;
55
+ };
56
+
57
+ const ALARM_INTERVAL_MS = 15_000;
58
+ const ACTOR_KEY = "actor";
59
+ const CALL_PREFIX = "call:";
60
+
61
+ export class CallSignalingDurableObject {
62
+ private hub: CallHub | null = null;
63
+ private actorApId: string | null = null;
64
+
65
+ constructor(
66
+ private readonly state: DoState,
67
+ private readonly env: CallDoEnv,
68
+ ) {}
69
+
70
+ // -------------------------------------------------------------------------
71
+ // HTTP entry (from the CloudflareSignalingHub adapter)
72
+ // -------------------------------------------------------------------------
73
+ async fetch(request: Request): Promise<Response> {
74
+ const url = new URL(request.url);
75
+ if (url.pathname === "/_ws") {
76
+ return this.handleUpgrade(request, url);
77
+ }
78
+ if (url.pathname === "/_ingest") {
79
+ return this.handleIngest(request);
80
+ }
81
+ return new Response("not found", { status: 404 });
82
+ }
83
+
84
+ private async handleUpgrade(request: Request, url: URL): Promise<Response> {
85
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
86
+ return new Response("expected websocket", { status: 426 });
87
+ }
88
+ const actor =
89
+ request.headers.get("X-Call-Actor") ?? url.searchParams.get("actor");
90
+ if (!actor) return new Response("missing actor", { status: 400 });
91
+ await this.setActor(actor);
92
+
93
+ const pair = new WebSocketPair();
94
+ const client = pair[0];
95
+ const server = pair[1];
96
+ this.state.acceptWebSocket(server);
97
+ await this.scheduleAlarm();
98
+ return new Response(null, {
99
+ status: 101,
100
+ // `webSocket` is a Cloudflare-specific ResponseInit field.
101
+ webSocket: client,
102
+ } as unknown as ResponseInit);
103
+ }
104
+
105
+ private async handleIngest(request: Request): Promise<Response> {
106
+ let body: unknown;
107
+ try {
108
+ body = await request.json();
109
+ } catch {
110
+ return new Response("bad json", { status: 400 });
111
+ }
112
+ const envelope = parseRtcSignalEnvelope(body);
113
+ if (!envelope) return new Response("bad envelope", { status: 400 });
114
+ await this.setActor(envelope.to);
115
+ const hub = await this.ensureHub();
116
+ if (!hub) return new Response("no actor", { status: 409 });
117
+ await hub.handleInboundSignal(envelope);
118
+ await this.scheduleAlarm();
119
+ return new Response(null, { status: 204 });
120
+ }
121
+
122
+ // -------------------------------------------------------------------------
123
+ // Hibernatable WebSocket events
124
+ // -------------------------------------------------------------------------
125
+ async webSocketMessage(
126
+ ws: DoWebSocket,
127
+ message: string | ArrayBuffer,
128
+ ): Promise<void> {
129
+ if (typeof message !== "string") return;
130
+ let frame: ClientToHubFrame;
131
+ try {
132
+ const parsed = JSON.parse(message);
133
+ if (!parsed || typeof parsed.t !== "string") return;
134
+ frame = parsed as ClientToHubFrame;
135
+ } catch {
136
+ return;
137
+ }
138
+ const hub = await this.ensureHub();
139
+ if (!hub) {
140
+ this.send(ws, { t: "error", code: "no_session" });
141
+ return;
142
+ }
143
+ await hub.handleClientFrame(this.wrap(ws), frame);
144
+ await this.scheduleAlarm();
145
+ }
146
+
147
+ async webSocketClose(ws: DoWebSocket): Promise<void> {
148
+ try {
149
+ ws.close();
150
+ } catch {
151
+ // already closing
152
+ }
153
+ }
154
+
155
+ async webSocketError(): Promise<void> {
156
+ // getWebSockets() excludes the errored socket automatically.
157
+ }
158
+
159
+ async alarm(): Promise<void> {
160
+ const hub = await this.ensureHub();
161
+ hub?.tick();
162
+ const active = (hub?.activeCalls().length ?? 0) > 0;
163
+ const connected = this.state.getWebSockets().length > 0;
164
+ if (active || connected) await this.scheduleAlarm(true);
165
+ }
166
+
167
+ // -------------------------------------------------------------------------
168
+ // Internals
169
+ // -------------------------------------------------------------------------
170
+ private async setActor(actor: string): Promise<void> {
171
+ if (this.actorApId === actor) return;
172
+ this.actorApId = actor;
173
+ await this.state.storage.put(ACTOR_KEY, actor);
174
+ }
175
+
176
+ private async ensureHub(): Promise<CallHub | null> {
177
+ if (this.hub) return this.hub;
178
+ const actor =
179
+ this.actorApId ?? (await this.state.storage.get<string>(ACTOR_KEY));
180
+ if (!actor) return null;
181
+ this.actorApId = actor;
182
+
183
+ const db = getDb(this.env.DB);
184
+ const storage = this.state.storage;
185
+ const base = createCallHubPort({
186
+ localActorApId: actor,
187
+ db,
188
+ env: this.env,
189
+ broadcast: (frame: HubToClientFrame) => {
190
+ for (const ws of this.state.getWebSockets()) this.send(ws, frame);
191
+ },
192
+ hasClients: () => this.state.getWebSockets().length > 0,
193
+ });
194
+ // Layer durable DO storage on top of the D1 persist so the in-memory call
195
+ // map survives hibernation.
196
+ const hub = new CallHub({
197
+ ...base,
198
+ persist: async (call: CallRecord) => {
199
+ if (isTerminalCallState(call.state)) {
200
+ await storage.delete(`${CALL_PREFIX}${call.callId}`);
201
+ } else {
202
+ await storage.put(`${CALL_PREFIX}${call.callId}`, call);
203
+ }
204
+ await base.persist?.(call);
205
+ },
206
+ });
207
+ const stored = await storage.list<CallRecord>({ prefix: CALL_PREFIX });
208
+ hub.hydrate([...stored.values()]);
209
+ this.hub = hub;
210
+ return hub;
211
+ }
212
+
213
+ private wrap(ws: DoWebSocket) {
214
+ return {
215
+ send: (frame: HubToClientFrame) => this.send(ws, frame),
216
+ close: (code?: number, reason?: string) => {
217
+ try {
218
+ ws.close(code, reason);
219
+ } catch {
220
+ // ignore
221
+ }
222
+ },
223
+ };
224
+ }
225
+
226
+ private send(ws: DoWebSocket, frame: HubToClientFrame): void {
227
+ try {
228
+ ws.send(JSON.stringify(frame));
229
+ } catch {
230
+ // socket gone; getWebSockets() will drop it
231
+ }
232
+ }
233
+
234
+ private async scheduleAlarm(force = false): Promise<void> {
235
+ const existing = await this.state.storage.getAlarm();
236
+ if (existing !== null && !force) return;
237
+ await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS);
238
+ }
239
+ }
240
+
241
+ // Re-exported here so the type is available to callers that only import the DO.
242
+ export type { RtcSignalEnvelopeV1 };
@@ -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 };
@@ -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,10 @@ 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;
122
150
  } & EnvVars;
123
151
 
124
152
  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
+ );
@@ -15,4 +15,5 @@ export * from "./stories.ts";
15
15
  export * from "./notes.ts";
16
16
  export * from "./messaging.ts";
17
17
  export * from "./mobile.ts";
18
+ export * from "./calls.ts";
18
19
  export * from "./relations.ts";