@takosjp/yurucommu-core 3.3.0 → 3.4.1

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.
Files changed (52) hide show
  1. package/migrations/0022_inbound_dispatch_claims.sql +17 -0
  2. package/package.json +3 -2
  3. package/packages/api/package.json +1 -1
  4. package/packages/api/src/index.ts +1 -0
  5. package/packages/api/src/lib/api/notifications.ts +1 -0
  6. package/packages/api/src/lib/api/posts.ts +1 -0
  7. package/packages/api/src/lib/rtc-client.ts +1 -3
  8. package/packages/api/src/types/call.ts +2 -10
  9. package/packages/api/src/types/index.ts +3 -0
  10. package/packages/api/src/types/realtime.ts +139 -0
  11. package/src/backend/index.ts +54 -12
  12. package/src/backend/lib/delivery/queue-batching.ts +53 -36
  13. package/src/backend/lib/delivery/queue-delivery.ts +3 -3
  14. package/src/backend/lib/delivery/queue.ts +17 -9
  15. package/src/backend/lib/delivery/types.ts +12 -0
  16. package/src/backend/lib/notification-push.ts +2 -2
  17. package/src/backend/lib/oauth-providers.ts +9 -0
  18. package/src/backend/lib/strip-image-metadata.ts +50 -30
  19. package/src/backend/lib/unread-counts.ts +63 -2
  20. package/src/backend/middleware/bearer-auth.ts +24 -9
  21. package/src/backend/public.ts +25 -1
  22. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
  23. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
  24. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
  25. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
  26. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
  27. package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
  28. package/src/backend/routes/activitypub/inbox-types.ts +8 -0
  29. package/src/backend/routes/activitypub/inbox.ts +410 -205
  30. package/src/backend/routes/activitypub/outbox.ts +0 -0
  31. package/src/backend/routes/auth.ts +2 -1
  32. package/src/backend/routes/communities/messages.ts +53 -17
  33. package/src/backend/routes/dm/messages.ts +47 -7
  34. package/src/backend/routes/dm/read-archive.ts +27 -0
  35. package/src/backend/routes/dm/typing.ts +16 -0
  36. package/src/backend/routes/notifications.ts +25 -0
  37. package/src/backend/routes/posts/post-helpers.ts +42 -23
  38. package/src/backend/routes/realtime/index.ts +67 -0
  39. package/src/backend/routes/rtc/index.ts +5 -1
  40. package/src/backend/runtime/call-hub-core.ts +13 -3
  41. package/src/backend/runtime/cloudflare.ts +63 -2
  42. package/src/backend/runtime/managed-relational.ts +197 -0
  43. package/src/backend/runtime/managed-runtime.ts +631 -0
  44. package/src/backend/runtime/queue.ts +40 -0
  45. package/src/backend/runtime/realtime-hub.ts +257 -0
  46. package/src/backend/runtime/realtime-stream-do.ts +323 -0
  47. package/src/backend/server.ts +15 -18
  48. package/src/backend/types.ts +13 -2
  49. package/src/db/d1-write.ts +270 -0
  50. package/src/db/index.ts +17 -0
  51. package/src/db/schema/federation.ts +19 -0
  52. package/src/db/schema/index.ts +1 -0
@@ -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
+ }
@@ -19,7 +19,6 @@
19
19
  * TAKOS_URL - Optional Takos API base URL for proxy/tool integration
20
20
  */
21
21
 
22
- import type { Message, MessageBatch, Queue } from "@cloudflare/workers-types";
23
22
  import { mkdir, readdir, readFile, stat } from "node:fs/promises";
24
23
  import process from "node:process";
25
24
  import { and, inArray, lt, or } from "drizzle-orm";
@@ -33,6 +32,13 @@ import type {
33
32
  import { buildDeliverEndpointMessage } from "./lib/delivery/queue.ts";
34
33
  import { deliveryQueue, getDbSQLite } from "../db/index.ts";
35
34
  import { logger } from "./lib/logger.ts";
35
+ import type {
36
+ IQueueBatch,
37
+ IQueueMessage,
38
+ IQueueProducer,
39
+ QueueBatchItem,
40
+ QueueSendOptions,
41
+ } from "./runtime/queue.ts";
36
42
 
37
43
  const log = logger.child({ component: "server.bootstrap" });
38
44
 
@@ -111,21 +117,12 @@ function isTruthyEnv(value: string | undefined): boolean {
111
117
 
112
118
  type LocalQueueBody = DeliveryQueueMessageV1 | DeliveryDlqMessageV1;
113
119
 
114
- type LocalQueueSendOptions = {
115
- delaySeconds?: number;
116
- };
117
-
118
- type LocalQueueBatchItem<T> = {
119
- body: T;
120
- delaySeconds?: number;
121
- };
122
-
123
120
  function createLocalMessageBatch<T extends LocalQueueBody>(
124
121
  queueName: string,
125
122
  bodies: T[],
126
123
  requeue: (body: T, delaySeconds?: number) => void,
127
- ): MessageBatch<T> {
128
- const messages = bodies.map((body): Message<T> => {
124
+ ): IQueueBatch<T> {
125
+ const messages = bodies.map((body): IQueueMessage<T> => {
129
126
  let settled = false;
130
127
  return {
131
128
  id: crypto.randomUUID(),
@@ -140,7 +137,7 @@ function createLocalMessageBatch<T extends LocalQueueBody>(
140
137
  settled = true;
141
138
  requeue(body, options?.delaySeconds);
142
139
  },
143
- } as Message<T>;
140
+ };
144
141
  });
145
142
 
146
143
  return {
@@ -152,13 +149,13 @@ function createLocalMessageBatch<T extends LocalQueueBody>(
152
149
  retryAll: (options?: { delaySeconds?: number }) => {
153
150
  for (const message of messages) message.retry(options);
154
151
  },
155
- } as unknown as MessageBatch<T>;
152
+ };
156
153
  }
157
154
 
158
155
  function createLocalQueue<T extends LocalQueueBody>(
159
156
  env: LocalServerEnv,
160
157
  queueName: string,
161
- ): Queue<T> {
158
+ ): IQueueProducer<T> {
162
159
  const pending: T[] = [];
163
160
  let draining = false;
164
161
  let drainScheduled = false;
@@ -206,15 +203,15 @@ function createLocalQueue<T extends LocalQueueBody>(
206
203
  }
207
204
 
208
205
  return {
209
- send: async (body: T, options?: LocalQueueSendOptions) => {
206
+ send: async (body: T, options?: QueueSendOptions) => {
210
207
  enqueue(body, options?.delaySeconds);
211
208
  },
212
- sendBatch: async (messages: Array<LocalQueueBatchItem<T>>) => {
209
+ sendBatch: async (messages: readonly QueueBatchItem<T>[]) => {
213
210
  for (const message of messages) {
214
211
  enqueue(message.body, message.delaySeconds);
215
212
  }
216
213
  },
217
- } as unknown as Queue<T>;
214
+ };
218
215
  }
219
216
 
220
217
  function attachLocalDeliveryQueues(env: LocalServerEnv): void {