@takosjp/yurucommu-core 3.3.0 → 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/package.json +1 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/rtc-client.ts +1 -3
- package/packages/api/src/types/call.ts +2 -10
- package/packages/api/src/types/index.ts +3 -0
- package/packages/api/src/types/realtime.ts +139 -0
- package/src/backend/index.ts +11 -0
- package/src/backend/lib/delivery/queue.ts +4 -0
- package/src/backend/lib/unread-counts.ts +63 -2
- package/src/backend/public.ts +3 -0
- 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 +5 -1
- package/src/backend/runtime/call-hub-core.ts +13 -3
- package/src/backend/runtime/realtime-hub.ts +257 -0
- package/src/backend/runtime/realtime-stream-do.ts +323 -0
- package/src/backend/types.ts +4 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime stream routes.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/realtime/config capability probe ({ available }) — clients
|
|
5
|
+
* gate their fallback polling on this
|
|
6
|
+
* POST /api/realtime/ticket mint a one-time short-lived WS ticket
|
|
7
|
+
* GET /api/realtime/socket browser WebSocket upgrade -> per-user stream
|
|
8
|
+
*
|
|
9
|
+
* Two upgrade auth paths, both terminating in the worker BEFORE the DO is
|
|
10
|
+
* reached (the DO binding is the trust boundary):
|
|
11
|
+
* - session: the same-origin browser sends its session cookie; the /api/*
|
|
12
|
+
* middleware resolved the actor already.
|
|
13
|
+
* - ticket: a cross-origin or bearer-auth client (the browser WebSocket API
|
|
14
|
+
* cannot set an Authorization header) first POSTs /ticket over the normal
|
|
15
|
+
* authenticated fetch path, then connects with ?actor=&ticket=. The ticket
|
|
16
|
+
* is minted inside — and re-verified + consumed by — the target user's own
|
|
17
|
+
* stream DO, so it is single-use, expires in ~60s, and never carries the
|
|
18
|
+
* raw session credential in a URL.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Hono } from "hono";
|
|
22
|
+
import type { Env, Variables } from "../../types.ts";
|
|
23
|
+
import {
|
|
24
|
+
getRealtimeHub,
|
|
25
|
+
isRealtimeAvailable,
|
|
26
|
+
} from "../../runtime/realtime-hub.ts";
|
|
27
|
+
|
|
28
|
+
const realtime = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
29
|
+
|
|
30
|
+
realtime.get("/config", (c) => {
|
|
31
|
+
return c.json({ available: isRealtimeAvailable(c.env) });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
realtime.post("/ticket", async (c) => {
|
|
35
|
+
const actor = c.get("actor");
|
|
36
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
37
|
+
if (!isRealtimeAvailable(c.env)) {
|
|
38
|
+
return c.json({ error: "realtime_unavailable" }, 503);
|
|
39
|
+
}
|
|
40
|
+
const ticket = await getRealtimeHub(c.env).mintTicket(actor.ap_id);
|
|
41
|
+
if (!ticket) return c.json({ error: "ticket_mint_failed" }, 500);
|
|
42
|
+
return c.json({ ticket, actor_ap_id: actor.ap_id });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
realtime.get("/socket", async (c) => {
|
|
46
|
+
if (!isRealtimeAvailable(c.env)) {
|
|
47
|
+
return c.json({ error: "realtime_unavailable" }, 503);
|
|
48
|
+
}
|
|
49
|
+
const hub = getRealtimeHub(c.env);
|
|
50
|
+
|
|
51
|
+
const sessionActor = c.get("actor");
|
|
52
|
+
if (sessionActor) {
|
|
53
|
+
return hub.upgrade(c.req.raw, sessionActor.ap_id, "session");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const actorParam = c.req.query("actor")?.trim();
|
|
57
|
+
const ticket = c.req.query("ticket")?.trim();
|
|
58
|
+
if (actorParam && ticket) {
|
|
59
|
+
// The actor param only selects WHICH stream DO verifies the ticket; a
|
|
60
|
+
// forged actor value fails inside that DO (it never minted the ticket).
|
|
61
|
+
return hub.upgrade(c.req.raw, actorParam, "ticket", ticket);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default realtime;
|
|
@@ -139,7 +139,11 @@ rtc.get("/api/rtc/calls", async (c) => {
|
|
|
139
139
|
rtc.get("/api/rtc/calls/:id", async (c) => {
|
|
140
140
|
const actor = c.get("actor");
|
|
141
141
|
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
142
|
-
const call = await getCallSession(
|
|
142
|
+
const call = await getCallSession(
|
|
143
|
+
c.get("db"),
|
|
144
|
+
actor.ap_id,
|
|
145
|
+
c.req.param("id"),
|
|
146
|
+
);
|
|
143
147
|
if (!call) return c.json({ error: "not_found" }, 404);
|
|
144
148
|
return c.json({ call });
|
|
145
149
|
});
|
|
@@ -98,7 +98,9 @@ export class CallHub {
|
|
|
98
98
|
|
|
99
99
|
/** Snapshot of active (non-terminal) calls — used by the DO to persist. */
|
|
100
100
|
activeCalls(): CallRecord[] {
|
|
101
|
-
return [...this.calls.values()].filter(
|
|
101
|
+
return [...this.calls.values()].filter(
|
|
102
|
+
(c) => !isTerminalCallState(c.state),
|
|
103
|
+
);
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/** Restore calls from durable storage after DO hibernation. */
|
|
@@ -175,7 +177,11 @@ export class CallHub {
|
|
|
175
177
|
conn.send({ t: "ready" });
|
|
176
178
|
// Re-announce any active calls so a reconnecting tab resyncs.
|
|
177
179
|
for (const call of this.activeCalls()) {
|
|
178
|
-
conn.send({
|
|
180
|
+
conn.send({
|
|
181
|
+
t: "call-state",
|
|
182
|
+
callId: call.callId,
|
|
183
|
+
state: call.state,
|
|
184
|
+
});
|
|
179
185
|
}
|
|
180
186
|
return;
|
|
181
187
|
case "ping":
|
|
@@ -401,7 +407,11 @@ export class CallHub {
|
|
|
401
407
|
const call = this.calls.get(envelope.callId);
|
|
402
408
|
if (!call || !envelope.sdp) return;
|
|
403
409
|
if (call.state === "ringing") this.transition(call, "connecting");
|
|
404
|
-
this.port.broadcast({
|
|
410
|
+
this.port.broadcast({
|
|
411
|
+
t: "answer",
|
|
412
|
+
callId: call.callId,
|
|
413
|
+
sdp: envelope.sdp,
|
|
414
|
+
});
|
|
405
415
|
}
|
|
406
416
|
|
|
407
417
|
private onPeerCandidate(envelope: RtcSignalEnvelopeV1): void {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|