@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.
@@ -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(c.get("db"), actor.ap_id, c.req.param("id"));
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((c) => !isTerminalCallState(c.state));
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({ t: "call-state", callId: call.callId, state: call.state });
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({ t: "answer", callId: call.callId, sdp: envelope.sdp });
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
+ }