@takosjp/yurucommu-core 3.2.0 → 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.
- package/migrations/0019_notification_push_delivery.sql +10 -7
- package/migrations/0020_call_sessions.sql +23 -0
- package/package.json +6 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/api/browser-push.ts +11 -0
- package/packages/api/src/lib/api/normalize.ts +15 -4
- package/packages/api/src/lib/api/notification-target.ts +106 -0
- package/packages/api/src/lib/api/push-config.ts +132 -0
- package/packages/api/src/lib/api.ts +2 -0
- package/packages/api/src/lib/rtc-client.ts +542 -0
- package/packages/api/src/social-server.ts +7 -0
- package/packages/api/src/types/call.ts +306 -0
- package/packages/api/src/types/index.ts +16 -4
- package/src/backend/index.ts +65 -10
- package/src/backend/lib/attachments.ts +52 -0
- package/src/backend/lib/delivery/queue.ts +55 -0
- package/src/backend/lib/notification-eligibility.ts +150 -0
- package/src/backend/lib/notification-push.ts +159 -146
- 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/session-actor.ts +16 -1
- package/src/backend/lib/unread-counts.ts +79 -0
- package/src/backend/middleware/csrf.ts +11 -0
- package/src/backend/public.ts +3 -0
- package/src/backend/routes/account-teardown.ts +13 -0
- package/src/backend/routes/activitypub.ts +5 -1
- package/src/backend/routes/auth-helpers.ts +10 -7
- package/src/backend/routes/auth.ts +131 -3
- package/src/backend/routes/communities/messages.ts +16 -33
- package/src/backend/routes/dm/contacts.ts +6 -44
- package/src/backend/routes/dm/messages.ts +5 -39
- package/src/backend/routes/notifications.ts +27 -70
- package/src/backend/routes/posts/transformers.ts +8 -10
- package/src/backend/routes/rtc/index.ts +147 -0
- package/src/backend/runtime/call-hub-core.ts +470 -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/signaling-hub.ts +187 -0
- package/src/backend/types.ts +28 -0
- package/src/db/index.ts +11 -10
- package/src/db/schema/calls.ts +46 -0
- package/src/db/schema/index.ts +1 -0
- package/src/db/schema/mobile.ts +7 -9
|
@@ -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 };
|
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,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 = {
|
package/src/db/index.ts
CHANGED
|
@@ -47,16 +47,17 @@ export async function getDbSQLite(databasePath: string): Promise<Database> {
|
|
|
47
47
|
const { drizzle } = await import("drizzle-orm/libsql");
|
|
48
48
|
|
|
49
49
|
const client = createClient({ url: `file:${databasePath}` });
|
|
50
|
-
// Foreign keys are explicitly turned OFF so the libsql engine matches
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
50
|
+
// Foreign keys are explicitly turned OFF so the libsql engine matches the
|
|
51
|
+
// MIGRATED production schema. Cloudflare D1 ENFORCES declared FKs (the old
|
|
52
|
+
// "D1 ignores FK" assumption was wrong — see migrations/0011, which exists
|
|
53
|
+
// precisely because enforcement broke inbound federation): remote actors
|
|
54
|
+
// live only in actor_cache, never in actors, so 0010/0011 REBUILT the
|
|
55
|
+
// affected tables to drop their actors FKs. Post-0011 the schema declares
|
|
56
|
+
// essentially no FKs and referential cleanup is handled at the app level by
|
|
57
|
+
// delete-cascade.ts / account-teardown.ts, identically on D1. NOTE: libsql
|
|
58
|
+
// (unlike bun:sqlite / stock SQLite, which default OFF) defaults
|
|
59
|
+
// foreign_keys ON, so this must be set explicitly — pre-0010 FKs still
|
|
60
|
+
// present in older local DB files must not diverge from D1 behavior.
|
|
60
61
|
await client.execute("PRAGMA foreign_keys = OFF");
|
|
61
62
|
sqliteDb = drizzle(client, { schema });
|
|
62
63
|
return sqliteDb;
|
|
@@ -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
|
+
);
|
package/src/db/schema/index.ts
CHANGED
package/src/db/schema/mobile.ts
CHANGED
|
@@ -47,14 +47,16 @@ export const mobilePushRegistrations = sqliteTable(
|
|
|
47
47
|
*
|
|
48
48
|
* `pushkey` is an opaque downstream-provider identifier. Provider credentials
|
|
49
49
|
* never live in this table; they stay in the configured stateless gateway.
|
|
50
|
+
*
|
|
51
|
+
* NO foreign key on actor_ap_id: D1 ENFORCES declared FKs (0010/0011 dropped
|
|
52
|
+
* the actors FKs for that reason) and cleanup is app-level
|
|
53
|
+
* (routes/account-teardown.ts), matching the rest of the schema.
|
|
50
54
|
*/
|
|
51
55
|
export const notificationPushers = sqliteTable(
|
|
52
56
|
"notification_pushers",
|
|
53
57
|
{
|
|
54
58
|
id: text("id").primaryKey(),
|
|
55
|
-
actorApId: text("actor_ap_id")
|
|
56
|
-
.notNull()
|
|
57
|
-
.references(() => actors.apId),
|
|
59
|
+
actorApId: text("actor_ap_id").notNull(),
|
|
58
60
|
product: text("product").notNull(),
|
|
59
61
|
scope: text("scope"),
|
|
60
62
|
kind: text("kind").notNull().default("http"),
|
|
@@ -75,13 +77,9 @@ export const notificationPushers = sqliteTable(
|
|
|
75
77
|
lastSeenAt: text("last_seen_at").notNull().$defaultFn(nowIsoUtc),
|
|
76
78
|
},
|
|
77
79
|
(t) => [
|
|
78
|
-
uniqueIndex("notification_pushers_actor_product_app_pushkey_idx").on(
|
|
79
|
-
t.actorApId,
|
|
80
|
-
t.product,
|
|
81
|
-
t.appId,
|
|
82
|
-
t.pushkeyHash,
|
|
83
|
-
),
|
|
84
80
|
index("notification_pushers_actor_product_idx").on(t.actorApId, t.product),
|
|
81
|
+
// Device uniqueness — strictly stronger than any actor-scoped unique
|
|
82
|
+
// variant, so this is the ONLY unique index.
|
|
85
83
|
uniqueIndex("notification_pushers_device_idx").on(
|
|
86
84
|
t.product,
|
|
87
85
|
t.appId,
|