@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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HubPort construction. Both the Cloudflare DO and the in-process Bun
|
|
3
|
+
* hub build their `HubPort` from these deps, so signer loading, media
|
|
4
|
+
* provisioning, and durable persistence are single-sourced.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { eq } from "drizzle-orm";
|
|
8
|
+
import type { Database } from "../../db/index.ts";
|
|
9
|
+
import { actors } from "../../db/index.ts";
|
|
10
|
+
import type { EnvVars } from "../types.ts";
|
|
11
|
+
import type { RtcSignalEnvelopeV1 } from "../../../packages/api/src/types/call.ts";
|
|
12
|
+
import type { CallRecord, HubConnection, HubPort } from "./call-hub-core.ts";
|
|
13
|
+
import {
|
|
14
|
+
type CallSigner,
|
|
15
|
+
sendCallSignal,
|
|
16
|
+
} from "../lib/rtc/signal-transport.ts";
|
|
17
|
+
import { createRtcProvider } from "../lib/rtc/provider.ts";
|
|
18
|
+
import { upsertCallSession } from "../lib/rtc/call-store.ts";
|
|
19
|
+
import { logger } from "../lib/logger.ts";
|
|
20
|
+
|
|
21
|
+
const log = logger.child({ component: "call.hub" });
|
|
22
|
+
|
|
23
|
+
export interface CallHubPortDeps {
|
|
24
|
+
localActorApId: string;
|
|
25
|
+
db: Database;
|
|
26
|
+
env: EnvVars;
|
|
27
|
+
broadcast(frame: Parameters<HubConnection["send"]>[0]): void;
|
|
28
|
+
hasClients(): boolean;
|
|
29
|
+
ring?(envelope: RtcSignalEnvelopeV1): Promise<void> | void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createCallHubPort(deps: CallHubPortDeps): HubPort {
|
|
33
|
+
const provider = createRtcProvider(deps.env);
|
|
34
|
+
let signer: CallSigner | null = null;
|
|
35
|
+
|
|
36
|
+
const loadSigner = async (): Promise<CallSigner> => {
|
|
37
|
+
if (signer) return signer;
|
|
38
|
+
const row = await deps.db.query.actors.findFirst({
|
|
39
|
+
where: eq(actors.apId, deps.localActorApId),
|
|
40
|
+
columns: { apId: true, privateKeyPem: true },
|
|
41
|
+
});
|
|
42
|
+
if (!row?.privateKeyPem) {
|
|
43
|
+
throw new Error(`no signing key for local actor ${deps.localActorApId}`);
|
|
44
|
+
}
|
|
45
|
+
signer = { apId: row.apId, privateKeyPem: row.privateKeyPem };
|
|
46
|
+
return signer;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
localActorApId: deps.localActorApId,
|
|
51
|
+
broadcast: deps.broadcast,
|
|
52
|
+
hasClients: deps.hasClients,
|
|
53
|
+
ring: deps.ring,
|
|
54
|
+
now: () => Date.now(),
|
|
55
|
+
log: (event, data) => log.info(event, data),
|
|
56
|
+
async sendToPeer(envelope, peerSignalEndpoint) {
|
|
57
|
+
const s = await loadSigner();
|
|
58
|
+
await sendCallSignal(deps.db, s, envelope, peerSignalEndpoint);
|
|
59
|
+
},
|
|
60
|
+
async provisionMedia(media) {
|
|
61
|
+
const [iceServers, sfuFocus] = await Promise.all([
|
|
62
|
+
provider.getIceServers(),
|
|
63
|
+
provider.getSfuFocus(media),
|
|
64
|
+
]);
|
|
65
|
+
return { iceServers, sfuFocus };
|
|
66
|
+
},
|
|
67
|
+
async persist(call: CallRecord) {
|
|
68
|
+
try {
|
|
69
|
+
await upsertCallSession(deps.db, deps.localActorApId, call);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
log.warn("call session persist failed", {
|
|
72
|
+
callId: call.callId,
|
|
73
|
+
error: String(err),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -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,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IRealtimeHub — the seam that decouples the realtime event stream from the
|
|
3
|
+
* Durable Object runtime (mirrors `signaling-hub.ts` for calls).
|
|
4
|
+
*
|
|
5
|
+
* On Cloudflare it forwards to the per-user `RealtimeStreamDO`
|
|
6
|
+
* (`idFromName(actorApId)`); on a runtime without the DO binding the hub is a
|
|
7
|
+
* null object: `emit` is a no-op and upgrades answer 503, so clients detect
|
|
8
|
+
* the missing capability (`GET /api/realtime/config`) and fall back to their
|
|
9
|
+
* low-frequency polling loops. Emits are ALWAYS best-effort — a realtime
|
|
10
|
+
* delivery failure must never fail the REST write that produced it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { gt } from "drizzle-orm";
|
|
14
|
+
import type { Env } from "../types.ts";
|
|
15
|
+
import { notificationPushJobs } from "../../db/index.ts";
|
|
16
|
+
import { computeUnreadSnapshot } from "../lib/unread-counts.ts";
|
|
17
|
+
import { logger } from "../lib/logger.ts";
|
|
18
|
+
|
|
19
|
+
const log = logger.child({ component: "realtime.hub" });
|
|
20
|
+
|
|
21
|
+
export interface IRealtimeHub {
|
|
22
|
+
/** Forward a browser WebSocket upgrade to `actorApId`'s stream (101). */
|
|
23
|
+
upgrade(
|
|
24
|
+
request: Request,
|
|
25
|
+
actorApId: string,
|
|
26
|
+
auth: "session" | "ticket",
|
|
27
|
+
ticket?: string,
|
|
28
|
+
): Promise<Response>;
|
|
29
|
+
/** Mint a one-time short-lived WS ticket inside the user's stream DO. */
|
|
30
|
+
mintTicket(actorApId: string): Promise<string | null>;
|
|
31
|
+
/** Push one event to `actorApId`'s live sockets (best-effort). */
|
|
32
|
+
emit(
|
|
33
|
+
actorApId: string,
|
|
34
|
+
type: string,
|
|
35
|
+
data: Record<string, unknown>,
|
|
36
|
+
): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Cloudflare: forward to the per-user Durable Object
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
class CloudflareRealtimeHub implements IRealtimeHub {
|
|
43
|
+
constructor(private readonly ns: DurableObjectNamespace) {}
|
|
44
|
+
|
|
45
|
+
private stub(actorApId: string): DurableObjectStub {
|
|
46
|
+
return this.ns.get(this.ns.idFromName(actorApId));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async upgrade(
|
|
50
|
+
request: Request,
|
|
51
|
+
actorApId: string,
|
|
52
|
+
auth: "session" | "ticket",
|
|
53
|
+
ticket?: string,
|
|
54
|
+
): Promise<Response> {
|
|
55
|
+
const headers = new Headers(request.headers);
|
|
56
|
+
headers.set("X-Realtime-Auth", auth);
|
|
57
|
+
if (ticket) headers.set("X-Realtime-Ticket", ticket);
|
|
58
|
+
const forwarded = new Request("https://realtime-do/_ws", {
|
|
59
|
+
method: "GET",
|
|
60
|
+
headers,
|
|
61
|
+
});
|
|
62
|
+
return this.stub(actorApId).fetch(
|
|
63
|
+
forwarded as unknown as Parameters<DurableObjectStub["fetch"]>[0],
|
|
64
|
+
) as unknown as Promise<Response>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async mintTicket(actorApId: string): Promise<string | null> {
|
|
68
|
+
const response = await this.stub(actorApId).fetch(
|
|
69
|
+
"https://realtime-do/_ticket",
|
|
70
|
+
{ method: "POST" },
|
|
71
|
+
);
|
|
72
|
+
if (!response.ok) return null;
|
|
73
|
+
const body = (await response.json()) as { ticket?: unknown };
|
|
74
|
+
return typeof body.ticket === "string" ? body.ticket : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async emit(
|
|
78
|
+
actorApId: string,
|
|
79
|
+
type: string,
|
|
80
|
+
data: Record<string, unknown>,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
await this.stub(actorApId).fetch("https://realtime-do/_emit", {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ type, data }),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Null hub (no DO binding): clients fall back to polling
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
class NullRealtimeHub implements IRealtimeHub {
|
|
94
|
+
async upgrade(): Promise<Response> {
|
|
95
|
+
return new Response(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
error: "realtime_unavailable",
|
|
98
|
+
message: "Realtime streaming requires the Durable Objects runtime.",
|
|
99
|
+
}),
|
|
100
|
+
{ status: 503, headers: { "Content-Type": "application/json" } },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async mintTicket(): Promise<string | null> {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async emit(): Promise<void> {
|
|
109
|
+
// no-op: clients poll
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const nullHub = new NullRealtimeHub();
|
|
114
|
+
|
|
115
|
+
/** Resolve the realtime hub for this runtime. */
|
|
116
|
+
export function getRealtimeHub(env: Env): IRealtimeHub {
|
|
117
|
+
if (env.REALTIME_STREAM) {
|
|
118
|
+
return new CloudflareRealtimeHub(env.REALTIME_STREAM);
|
|
119
|
+
}
|
|
120
|
+
return nullHub;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether realtime streaming can be served on this runtime. */
|
|
124
|
+
export function isRealtimeAvailable(env: Env): boolean {
|
|
125
|
+
return Boolean(env.REALTIME_STREAM);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Best-effort emit helpers (producers call these; failures never propagate)
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export interface RealtimeEmitInput {
|
|
133
|
+
actorApId: string;
|
|
134
|
+
type: string;
|
|
135
|
+
data: Record<string, unknown>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Emit a batch of events, swallowing (but logging) any delivery failure. */
|
|
139
|
+
export async function emitRealtimeBestEffort(
|
|
140
|
+
env: Env,
|
|
141
|
+
events: RealtimeEmitInput[],
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
if (!isRealtimeAvailable(env) || events.length === 0) return;
|
|
144
|
+
const hub = getRealtimeHub(env);
|
|
145
|
+
await Promise.all(
|
|
146
|
+
events.map(async ({ actorApId, type, data }) => {
|
|
147
|
+
try {
|
|
148
|
+
await hub.emit(actorApId, type, data);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
log.warn("Realtime emit failed", {
|
|
151
|
+
event: "realtime.emit_failed",
|
|
152
|
+
type,
|
|
153
|
+
error,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Compute and push the authoritative unread counters for one user. The
|
|
162
|
+
* counters are always server-derived (the same SQL as the badge endpoints) so
|
|
163
|
+
* a pushed badge can never drift from what the client would fetch.
|
|
164
|
+
*/
|
|
165
|
+
export async function emitUnreadSnapshot(
|
|
166
|
+
env: Env,
|
|
167
|
+
actorApId: string,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
if (!isRealtimeAvailable(env)) return;
|
|
170
|
+
try {
|
|
171
|
+
const snapshot = await computeUnreadSnapshot(env.DB_INSTANCE, actorApId);
|
|
172
|
+
await getRealtimeHub(env).emit(actorApId, "unread", {
|
|
173
|
+
dm: snapshot.dm,
|
|
174
|
+
community: snapshot.community,
|
|
175
|
+
talk_total: snapshot.talkTotal,
|
|
176
|
+
notifications: snapshot.notifications,
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
log.warn("Realtime unread emit failed", {
|
|
180
|
+
event: "realtime.unread_emit_failed",
|
|
181
|
+
error,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Schedule best-effort realtime work after the response is sent. Mirrors the
|
|
188
|
+
* push-outbox sweep in index.ts: prefer `executionCtx.waitUntil`, fall back to
|
|
189
|
+
* awaiting inline where no runtime context exists (tests / plain fetch).
|
|
190
|
+
*/
|
|
191
|
+
export async function runRealtimeAfterResponse(
|
|
192
|
+
c: { executionCtx?: { waitUntil?: (p: Promise<unknown>) => void } },
|
|
193
|
+
task: () => Promise<void>,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
const wrapped = task().catch((error) => {
|
|
196
|
+
log.warn("Realtime after-response task failed", {
|
|
197
|
+
event: "realtime.after_response_failed",
|
|
198
|
+
error,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
const ctx = c.executionCtx;
|
|
203
|
+
if (ctx && typeof ctx.waitUntil === "function") {
|
|
204
|
+
ctx.waitUntil(wrapped);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// No execution context; await inline below.
|
|
209
|
+
}
|
|
210
|
+
await wrapped;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Notification sweep (the same choke points that flush the push outbox)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
// Every unread inbox insert is captured by the notification_push_jobs DB
|
|
218
|
+
// trigger — the single choke point covering all nine scattered insert sites
|
|
219
|
+
// (follow/like/reply/mention/DM/federation/community fanout). A DB trigger
|
|
220
|
+
// cannot call a Durable Object, so this sweep reads the jobs the trigger
|
|
221
|
+
// wrote and emits `notification.new` + `unread` to each affected user. It is
|
|
222
|
+
// called from the SAME two flush points as `enqueuePendingNotificationPushJobs`
|
|
223
|
+
// (the post-response middleware and the queue-consumer tail).
|
|
224
|
+
//
|
|
225
|
+
// The cursor is per-isolate in-memory, initialized to isolate start so a cold
|
|
226
|
+
// isolate never replays history; a double-emit across isolates is harmless
|
|
227
|
+
// (clients treat both event types idempotently: refetch + set-counter).
|
|
228
|
+
let realtimeSweepCursor = new Date().toISOString();
|
|
229
|
+
|
|
230
|
+
export async function sweepRealtimeNotifications(env: Env): Promise<void> {
|
|
231
|
+
if (!isRealtimeAvailable(env)) return;
|
|
232
|
+
const since = realtimeSweepCursor;
|
|
233
|
+
const nextCursor = new Date().toISOString();
|
|
234
|
+
try {
|
|
235
|
+
const rows = await env.DB_INSTANCE.selectDistinct({
|
|
236
|
+
actorApId: notificationPushJobs.actorApId,
|
|
237
|
+
})
|
|
238
|
+
.from(notificationPushJobs)
|
|
239
|
+
.where(gt(notificationPushJobs.createdAt, since))
|
|
240
|
+
.limit(50);
|
|
241
|
+
realtimeSweepCursor = nextCursor;
|
|
242
|
+
if (rows.length === 0) return;
|
|
243
|
+
await Promise.all(
|
|
244
|
+
rows.map(async ({ actorApId }) => {
|
|
245
|
+
await emitRealtimeBestEffort(env, [
|
|
246
|
+
{ actorApId, type: "notification.new", data: {} },
|
|
247
|
+
]);
|
|
248
|
+
await emitUnreadSnapshot(env, actorApId);
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
log.warn("Realtime notification sweep failed", {
|
|
253
|
+
event: "realtime.sweep_failed",
|
|
254
|
+
error,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|