@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.
- package/migrations/0022_inbound_dispatch_claims.sql +17 -0
- package/package.json +3 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/api/notifications.ts +1 -0
- package/packages/api/src/lib/api/posts.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 +54 -12
- package/src/backend/lib/delivery/queue-batching.ts +53 -36
- package/src/backend/lib/delivery/queue-delivery.ts +3 -3
- package/src/backend/lib/delivery/queue.ts +17 -9
- package/src/backend/lib/delivery/types.ts +12 -0
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oauth-providers.ts +9 -0
- package/src/backend/lib/strip-image-metadata.ts +50 -30
- package/src/backend/lib/unread-counts.ts +63 -2
- package/src/backend/middleware/bearer-auth.ts +24 -9
- package/src/backend/public.ts +25 -1
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
- package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
- package/src/backend/routes/activitypub/inbox-types.ts +8 -0
- package/src/backend/routes/activitypub/inbox.ts +410 -205
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/auth.ts +2 -1
- 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/posts/post-helpers.ts +42 -23
- 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/cloudflare.ts +63 -2
- package/src/backend/runtime/managed-relational.ts +197 -0
- package/src/backend/runtime/managed-runtime.ts +631 -0
- package/src/backend/runtime/queue.ts +40 -0
- package/src/backend/runtime/realtime-hub.ts +257 -0
- package/src/backend/runtime/realtime-stream-do.ts +323 -0
- package/src/backend/server.ts +15 -18
- package/src/backend/types.ts +13 -2
- package/src/db/d1-write.ts +270 -0
- package/src/db/index.ts +17 -0
- package/src/db/schema/federation.ts +19 -0
- 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
|
+
}
|
package/src/backend/server.ts
CHANGED
|
@@ -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
|
-
):
|
|
128
|
-
const messages = bodies.map((body):
|
|
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
|
-
}
|
|
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
|
-
}
|
|
152
|
+
};
|
|
156
153
|
}
|
|
157
154
|
|
|
158
155
|
function createLocalQueue<T extends LocalQueueBody>(
|
|
159
156
|
env: LocalServerEnv,
|
|
160
157
|
queueName: string,
|
|
161
|
-
):
|
|
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?:
|
|
206
|
+
send: async (body: T, options?: QueueSendOptions) => {
|
|
210
207
|
enqueue(body, options?.delaySeconds);
|
|
211
208
|
},
|
|
212
|
-
sendBatch: async (messages:
|
|
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
|
-
}
|
|
214
|
+
};
|
|
218
215
|
}
|
|
219
216
|
|
|
220
217
|
function attachLocalDeliveryQueues(env: LocalServerEnv): void {
|