@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,17 @@
|
|
|
1
|
+
-- Fence concurrent/retried ActivityPub inbox dispatches.
|
|
2
|
+
--
|
|
3
|
+
-- `activities.processed = 0` says an activity is retryable, but it does not say
|
|
4
|
+
-- whether another Worker is actively dispatching it. Keeping the lease in a
|
|
5
|
+
-- separate table preserves the public activities ledger shape and lets a
|
|
6
|
+
-- crashed owner expire without allowing an old owner to commit over a newer
|
|
7
|
+
-- retry.
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS inbound_activity_claims (
|
|
10
|
+
activity_ap_id TEXT PRIMARY KEY,
|
|
11
|
+
processing_token TEXT,
|
|
12
|
+
lease_expires_at TEXT,
|
|
13
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE INDEX IF NOT EXISTS inbound_activity_claims_lease_idx
|
|
17
|
+
ON inbound_activity_claims(lease_expires_at);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takosjp/yurucommu-core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"start": "bun src/backend/server.ts",
|
|
60
60
|
"dev": "bun src/backend/server.ts",
|
|
61
61
|
"dev:server": "bun src/backend/server.ts",
|
|
62
|
-
"check": "tsc --noEmit && bun run check:no-opentofu-artifacts",
|
|
62
|
+
"check": "bun run fmt:check && tsc --noEmit && bun run check:no-opentofu-artifacts && bun run test",
|
|
63
63
|
"check:no-opentofu-artifacts": "bun scripts/check-no-opentofu-artifacts.mjs",
|
|
64
64
|
"test": "bun run build:api && bun test test/ src/backend/ packages/api/src/ scripts/check-publish-version-discipline.test.ts scripts/publish-package-resumable.test.ts && bun run check:release-contents",
|
|
65
65
|
"test:backend": "bun test src/backend/",
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@aws-sdk/client-s3": "^3.971.0",
|
|
84
84
|
"@libsql/client": "^0.17.0",
|
|
85
|
+
"@takosjp/takosumi-contract": "^1.0.0",
|
|
85
86
|
"better-sqlite3": "^12.4.1",
|
|
86
87
|
"drizzle-orm": "^0.45.1",
|
|
87
88
|
"fdir": "^6.5.0",
|
|
@@ -39,6 +39,7 @@ export async function fetchNotifications(options?: {
|
|
|
39
39
|
|
|
40
40
|
export async function fetchUnreadCount(): Promise<number> {
|
|
41
41
|
const res = await apiFetch("/api/notifications/unread/count");
|
|
42
|
+
await assertOk(res, "Failed to load unread notification count");
|
|
42
43
|
const data = (await res.json()) as { count?: number };
|
|
43
44
|
return data.count || 0;
|
|
44
45
|
}
|
|
@@ -169,6 +169,7 @@ export async function fetchBookmarks(options?: {
|
|
|
169
169
|
if (options?.before) params.set("before", options.before);
|
|
170
170
|
const query = params.toString() ? `?${params}` : "";
|
|
171
171
|
const res = await apiFetch(`/api/bookmarks${query}`);
|
|
172
|
+
await assertOk(res, "Failed to load bookmarks");
|
|
172
173
|
const data = (await res.json()) as PostListResponse & {
|
|
173
174
|
next_cursor?: string | null;
|
|
174
175
|
has_more?: boolean;
|
|
@@ -313,9 +313,7 @@ export class CallClient {
|
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
private onRinging(
|
|
317
|
-
frame: Extract<HubToClientFrame, { t: "ringing" }>,
|
|
318
|
-
): void {
|
|
316
|
+
private onRinging(frame: Extract<HubToClientFrame, { t: "ringing" }>): void {
|
|
319
317
|
if (this.call) {
|
|
320
318
|
// Already busy — auto-decline the second ring.
|
|
321
319
|
this.send({ t: "reject", callId: frame.callId, reason: "busy" });
|
|
@@ -27,13 +27,7 @@ export interface CallMediaKind {
|
|
|
27
27
|
|
|
28
28
|
/** Cross-instance signaling message kinds (Matrix-VoIP inspired). */
|
|
29
29
|
export type RtcSignalType =
|
|
30
|
-
| "
|
|
31
|
-
| "answer"
|
|
32
|
-
| "candidate"
|
|
33
|
-
| "accept"
|
|
34
|
-
| "reject"
|
|
35
|
-
| "hangup"
|
|
36
|
-
| "cancel";
|
|
30
|
+
"offer" | "answer" | "candidate" | "accept" | "reject" | "hangup" | "cancel";
|
|
37
31
|
|
|
38
32
|
/**
|
|
39
33
|
* Selected SFU focus for a group call. `null`/absent means pure P2P (1:1).
|
|
@@ -286,9 +280,7 @@ export function parseRtcSignalEnvelope(
|
|
|
286
280
|
candidates: parseCandidates(input.candidates),
|
|
287
281
|
sfuFocus: parseSfuFocus(input.sfuFocus),
|
|
288
282
|
reason:
|
|
289
|
-
typeof input.reason === "string"
|
|
290
|
-
? input.reason.slice(0, 200)
|
|
291
|
-
: undefined,
|
|
283
|
+
typeof input.reason === "string" ? input.reason.slice(0, 200) : undefined,
|
|
292
284
|
ts,
|
|
293
285
|
ttlMs,
|
|
294
286
|
};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Call signaling wire contract + browser<->hub frames (voice + video).
|
|
2
2
|
export * from "./call.ts";
|
|
3
3
|
|
|
4
|
+
// Realtime stream wire contract (per-user event feed + control frames).
|
|
5
|
+
export * from "./realtime.ts";
|
|
6
|
+
|
|
4
7
|
// ===== Yurucommu AP-Native Types =====
|
|
5
8
|
|
|
6
9
|
// Actor represents a user (Person) in ActivityPub
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime stream wire contract (browser <-> per-user RealtimeStreamDO).
|
|
3
|
+
*
|
|
4
|
+
* One authenticated WebSocket per user carries every live update the client
|
|
5
|
+
* used to poll for: talk messages, typing, read receipts, contact-list
|
|
6
|
+
* changes, new notifications, and the authoritative unread counters. The
|
|
7
|
+
* server pushes `RealtimeEvent` envelopes; the client sends only the small
|
|
8
|
+
* control frames below (writes stay on the REST API).
|
|
9
|
+
*
|
|
10
|
+
* Event ids are a per-user monotonic sequence assigned by the Durable Object.
|
|
11
|
+
* A reconnecting client offers its last seen id in `hello`; the DO replays the
|
|
12
|
+
* gap from its ring buffer, or answers `resync` when the gap is older than the
|
|
13
|
+
* buffer so the client re-fetches via the normal REST reads.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type RealtimeEventType =
|
|
17
|
+
| "talk.message"
|
|
18
|
+
| "talk.typing"
|
|
19
|
+
| "talk.read"
|
|
20
|
+
| "talk.contacts_changed"
|
|
21
|
+
| "notification.new"
|
|
22
|
+
| "unread";
|
|
23
|
+
|
|
24
|
+
export interface RealtimeEvent {
|
|
25
|
+
/** Per-user monotonic sequence number (assigned by the stream DO). */
|
|
26
|
+
id: number;
|
|
27
|
+
type: RealtimeEventType;
|
|
28
|
+
data: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `talk.message` payload. `other_ap_id` is from the RECEIVING user's view. */
|
|
32
|
+
export interface TalkMessageEventData {
|
|
33
|
+
kind: "dm" | "community";
|
|
34
|
+
/** DM: the counterpart actor (per-recipient). */
|
|
35
|
+
other_ap_id?: string;
|
|
36
|
+
/** Community chat: the community actor. */
|
|
37
|
+
community_ap_id?: string;
|
|
38
|
+
conversation_id?: string;
|
|
39
|
+
message: {
|
|
40
|
+
id: string;
|
|
41
|
+
sender: {
|
|
42
|
+
ap_id: string;
|
|
43
|
+
username: string;
|
|
44
|
+
preferred_username: string | null;
|
|
45
|
+
name: string | null;
|
|
46
|
+
icon_url: string | null;
|
|
47
|
+
};
|
|
48
|
+
content: string | null;
|
|
49
|
+
attachments?: unknown[];
|
|
50
|
+
created_at: string | null;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TalkTypingEventData {
|
|
55
|
+
other_ap_id: string;
|
|
56
|
+
is_typing: boolean;
|
|
57
|
+
typed_at: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TalkReadEventData {
|
|
61
|
+
other_ap_id: string;
|
|
62
|
+
conversation_id: string;
|
|
63
|
+
last_read_at: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Authoritative unread counters (server-computed; never client-derived). */
|
|
67
|
+
export interface UnreadEventData {
|
|
68
|
+
dm: number;
|
|
69
|
+
community: number;
|
|
70
|
+
talk_total: number;
|
|
71
|
+
notifications: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Client -> server frames -------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export type RealtimeClientFrame =
|
|
77
|
+
{ t: "hello"; lastEventId?: number } | { t: "ping" } | { t: "pong" };
|
|
78
|
+
|
|
79
|
+
// --- Server -> client frames -------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export type RealtimeServerFrame =
|
|
82
|
+
| { t: "hello_ok"; lastEventId: number }
|
|
83
|
+
| { t: "event"; event: RealtimeEvent }
|
|
84
|
+
/** The requested replay gap is older than the buffer: re-fetch via REST. */
|
|
85
|
+
| { t: "resync" }
|
|
86
|
+
| { t: "ping" }
|
|
87
|
+
| { t: "pong" };
|
|
88
|
+
|
|
89
|
+
export function parseRealtimeClientFrame(
|
|
90
|
+
raw: unknown,
|
|
91
|
+
): RealtimeClientFrame | null {
|
|
92
|
+
if (!raw || typeof raw !== "object") return null;
|
|
93
|
+
const frame = raw as { t?: unknown; lastEventId?: unknown };
|
|
94
|
+
if (frame.t === "ping" || frame.t === "pong") return { t: frame.t };
|
|
95
|
+
if (frame.t === "hello") {
|
|
96
|
+
const lastEventId =
|
|
97
|
+
typeof frame.lastEventId === "number" &&
|
|
98
|
+
Number.isFinite(frame.lastEventId) &&
|
|
99
|
+
frame.lastEventId >= 0
|
|
100
|
+
? Math.floor(frame.lastEventId)
|
|
101
|
+
: undefined;
|
|
102
|
+
return { t: "hello", lastEventId };
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseRealtimeServerFrame(
|
|
108
|
+
raw: unknown,
|
|
109
|
+
): RealtimeServerFrame | null {
|
|
110
|
+
if (!raw || typeof raw !== "object") return null;
|
|
111
|
+
const frame = raw as { t?: unknown; event?: unknown; lastEventId?: unknown };
|
|
112
|
+
if (frame.t === "ping" || frame.t === "pong" || frame.t === "resync") {
|
|
113
|
+
return { t: frame.t };
|
|
114
|
+
}
|
|
115
|
+
if (frame.t === "hello_ok" && typeof frame.lastEventId === "number") {
|
|
116
|
+
return { t: "hello_ok", lastEventId: frame.lastEventId };
|
|
117
|
+
}
|
|
118
|
+
if (frame.t === "event" && frame.event && typeof frame.event === "object") {
|
|
119
|
+
const event = frame.event as {
|
|
120
|
+
id?: unknown;
|
|
121
|
+
type?: unknown;
|
|
122
|
+
data?: unknown;
|
|
123
|
+
};
|
|
124
|
+
if (typeof event.id === "number" && typeof event.type === "string") {
|
|
125
|
+
return {
|
|
126
|
+
t: "event",
|
|
127
|
+
event: {
|
|
128
|
+
id: event.id,
|
|
129
|
+
type: event.type as RealtimeEventType,
|
|
130
|
+
data:
|
|
131
|
+
event.data && typeof event.data === "object"
|
|
132
|
+
? (event.data as Record<string, unknown>)
|
|
133
|
+
: {},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
package/src/backend/index.ts
CHANGED
|
@@ -4,8 +4,12 @@ import { NOTIFICATION_PUSHER_REGISTRATION_PATH } from "./lib/notification-pusher
|
|
|
4
4
|
import type { Env, EnvVars, Variables } from "./types.ts";
|
|
5
5
|
import { extractActorFromSession } from "./lib/session-actor.ts";
|
|
6
6
|
import { isBackendPath } from "./lib/backend-paths.ts";
|
|
7
|
-
import { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
|
|
8
7
|
import {
|
|
8
|
+
wrapCloudflareBindings,
|
|
9
|
+
wrapCloudflareMessageBatch,
|
|
10
|
+
} from "./runtime/cloudflare.ts";
|
|
11
|
+
import {
|
|
12
|
+
getMobileOidcAudience,
|
|
9
13
|
getOidcClientCredentials,
|
|
10
14
|
getOidcIssuerUrl,
|
|
11
15
|
} from "./lib/oauth-providers.ts";
|
|
@@ -30,6 +34,8 @@ import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
|
|
|
30
34
|
import mobileRoutes from "./routes/mobile.ts";
|
|
31
35
|
import notificationPusherRoutes from "./routes/notification-pushers.ts";
|
|
32
36
|
import rtcRoutes from "./routes/rtc/index.ts";
|
|
37
|
+
import realtimeRoutes from "./routes/realtime/index.ts";
|
|
38
|
+
import { sweepRealtimeNotifications } from "./runtime/realtime-hub.ts";
|
|
33
39
|
|
|
34
40
|
import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
|
|
35
41
|
import { csrfProtection } from "./middleware/csrf.ts";
|
|
@@ -42,7 +48,15 @@ import { logger } from "./lib/logger.ts";
|
|
|
42
48
|
|
|
43
49
|
const log = logger.child({ component: "backend.index" });
|
|
44
50
|
let lastNotificationPushRecoverySweep = 0;
|
|
45
|
-
import type {
|
|
51
|
+
import type { IQueueBatch } from "./runtime/queue.ts";
|
|
52
|
+
import type {
|
|
53
|
+
D1Database,
|
|
54
|
+
Fetcher,
|
|
55
|
+
KVNamespace,
|
|
56
|
+
MessageBatch,
|
|
57
|
+
Queue,
|
|
58
|
+
R2Bucket,
|
|
59
|
+
} from "@cloudflare/workers-types";
|
|
46
60
|
import type {
|
|
47
61
|
DeliveryDlqMessageV1,
|
|
48
62
|
DeliveryQueueMessageV1,
|
|
@@ -72,6 +86,14 @@ export interface YurucommuBackendPluginV1 {
|
|
|
72
86
|
export interface CreateYurucommuBackendAppOptionsV1 {
|
|
73
87
|
plugins?: YurucommuBackendPluginV1[];
|
|
74
88
|
discovery?: YurucommuBackendDiscoveryOptionsV1;
|
|
89
|
+
/**
|
|
90
|
+
* Browser capture capabilities required by this product shell. Both default
|
|
91
|
+
* to denied; a product must opt in to each capability it actually uses.
|
|
92
|
+
*/
|
|
93
|
+
browserMedia?: {
|
|
94
|
+
camera?: boolean;
|
|
95
|
+
microphone?: boolean;
|
|
96
|
+
};
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
export interface YurucommuBackendDiscoveryClientV1 {
|
|
@@ -341,7 +363,7 @@ function mountReadinessRoutes(
|
|
|
341
363
|
) => {
|
|
342
364
|
const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
|
|
343
365
|
const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
|
|
344
|
-
const
|
|
366
|
+
const clientId = getMobileOidcAudience(c.env);
|
|
345
367
|
return c.json(
|
|
346
368
|
buildSocialServerDiscovery(appUrl, issuer, discovery, {
|
|
347
369
|
oidcClientId: getOidcIssuerUrl(c.env)
|
|
@@ -502,7 +524,12 @@ function applyBodyLimits(app: YurucommuApp): void {
|
|
|
502
524
|
});
|
|
503
525
|
}
|
|
504
526
|
|
|
505
|
-
function applyGlobalMiddleware(
|
|
527
|
+
function applyGlobalMiddleware(
|
|
528
|
+
app: YurucommuApp,
|
|
529
|
+
browserMedia: NonNullable<
|
|
530
|
+
CreateYurucommuBackendAppOptionsV1["browserMedia"]
|
|
531
|
+
> = {},
|
|
532
|
+
): void {
|
|
506
533
|
app.onError(createErrorMiddleware());
|
|
507
534
|
|
|
508
535
|
app.use("*", async (c, next) => {
|
|
@@ -560,11 +587,14 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
560
587
|
setSecurityHeader("X-Content-Type-Options", "nosniff");
|
|
561
588
|
setSecurityHeader("X-Frame-Options", "DENY");
|
|
562
589
|
setSecurityHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
563
|
-
//
|
|
564
|
-
//
|
|
590
|
+
// Browser capture is product-specific and denied by default. Even when a
|
|
591
|
+
// capability is enabled, only this origin may use it; cross-origin frames
|
|
592
|
+
// and geolocation remain denied.
|
|
593
|
+
const camera = browserMedia.camera === true ? "(self)" : "()";
|
|
594
|
+
const microphone = browserMedia.microphone === true ? "(self)" : "()";
|
|
565
595
|
setSecurityHeader(
|
|
566
596
|
"Permissions-Policy",
|
|
567
|
-
|
|
597
|
+
`camera=${camera}, microphone=${microphone}, geolocation=()`,
|
|
568
598
|
);
|
|
569
599
|
// HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
|
|
570
600
|
// (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
|
|
@@ -600,6 +630,9 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
600
630
|
error,
|
|
601
631
|
});
|
|
602
632
|
}
|
|
633
|
+
// Same choke point feeds the realtime stream: the push-jobs the inbox
|
|
634
|
+
// trigger wrote tell us exactly which users gained a notification.
|
|
635
|
+
await sweepRealtimeNotifications(c.env);
|
|
603
636
|
})();
|
|
604
637
|
// Prefer to run the sweep AFTER the response is sent (waitUntil) so its
|
|
605
638
|
// 2-4 D1 round-trips never add latency to the request. `executionCtx`
|
|
@@ -780,6 +813,8 @@ function mountCoreRoutes(app: YurucommuApp): void {
|
|
|
780
813
|
app.route("/api/moderation", moderationRoutes);
|
|
781
814
|
app.route("/api/apps", appsApiRoutes);
|
|
782
815
|
app.route("/hosted", appsServeRoutes);
|
|
816
|
+
// Realtime stream: capability probe + WS ticket + per-user socket upgrade.
|
|
817
|
+
app.route("/api/realtime", realtimeRoutes);
|
|
783
818
|
// Call feature: /api/rtc/* (session) + /ap/rtc/signal (server-to-server).
|
|
784
819
|
app.route("/", rtcRoutes);
|
|
785
820
|
app.route("/", activitypubRoutes);
|
|
@@ -894,7 +929,7 @@ export function createYurucommuBackendApp(
|
|
|
894
929
|
// /healthz and /readyz stay reachable even when payload validation
|
|
895
930
|
// misbehaves.
|
|
896
931
|
applyBodyLimits(app);
|
|
897
|
-
applyGlobalMiddleware(app);
|
|
932
|
+
applyGlobalMiddleware(app, options.browserMedia);
|
|
898
933
|
for (const plugin of plugins) {
|
|
899
934
|
plugin.setup?.(pluginContext);
|
|
900
935
|
}
|
|
@@ -916,7 +951,7 @@ const app = createYurucommuBackendApp();
|
|
|
916
951
|
export const backendApp = app;
|
|
917
952
|
|
|
918
953
|
export async function handleYurucommuQueueBatch(
|
|
919
|
-
batch:
|
|
954
|
+
batch: IQueueBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
|
|
920
955
|
env: Env,
|
|
921
956
|
): Promise<void> {
|
|
922
957
|
const deliveryQueueName = env.DELIVERY_QUEUE_NAME ?? "yurucommu-delivery";
|
|
@@ -924,13 +959,13 @@ export async function handleYurucommuQueueBatch(
|
|
|
924
959
|
|
|
925
960
|
if (batch.queue === deliveryQueueName) {
|
|
926
961
|
return handleDeliveryQueueBatch(
|
|
927
|
-
batch as
|
|
962
|
+
batch as IQueueBatch<DeliveryQueueMessageV1>,
|
|
928
963
|
env,
|
|
929
964
|
);
|
|
930
965
|
}
|
|
931
966
|
if (batch.queue === deliveryDlqName) {
|
|
932
967
|
return handleDeliveryDlqBatch(
|
|
933
|
-
batch as
|
|
968
|
+
batch as IQueueBatch<DeliveryDlqMessageV1>,
|
|
934
969
|
env,
|
|
935
970
|
);
|
|
936
971
|
}
|
|
@@ -953,6 +988,10 @@ type WorkerBindings = EnvVars & {
|
|
|
953
988
|
// spreads it through untouched (it is not DB/MEDIA/KV/ASSETS) so app code and
|
|
954
989
|
// the rtc routes read it as c.env.CALL_SIGNALING.
|
|
955
990
|
CALL_SIGNALING?: DurableObjectNamespace;
|
|
991
|
+
// Per-user realtime event stream Durable Object namespace. Same pass-through
|
|
992
|
+
// as CALL_SIGNALING; optional — when unbound the realtime routes answer 503
|
|
993
|
+
// and clients fall back to polling.
|
|
994
|
+
REALTIME_STREAM?: DurableObjectNamespace;
|
|
956
995
|
};
|
|
957
996
|
|
|
958
997
|
export default {
|
|
@@ -968,6 +1007,9 @@ export default {
|
|
|
968
1007
|
batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
|
|
969
1008
|
bindings: WorkerBindings,
|
|
970
1009
|
): Promise<void> {
|
|
971
|
-
return handleYurucommuQueueBatch(
|
|
1010
|
+
return handleYurucommuQueueBatch(
|
|
1011
|
+
wrapCloudflareMessageBatch(batch),
|
|
1012
|
+
wrapCloudflareBindings(bindings),
|
|
1013
|
+
);
|
|
972
1014
|
},
|
|
973
1015
|
};
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* and batch dispatch of delivery messages.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { Message, MessageBatch } from "@cloudflare/workers-types";
|
|
7
6
|
import type { Env } from "../../types.ts";
|
|
7
|
+
import type { IQueueMessage } from "../../runtime/queue.ts";
|
|
8
8
|
import type { Database } from "../../../db/index.ts";
|
|
9
9
|
import { and, eq, or, sql } from "drizzle-orm";
|
|
10
10
|
import {
|
|
@@ -117,12 +117,17 @@ export async function enqueueFollowerEndpointDeliveries(
|
|
|
117
117
|
baseUrl: string,
|
|
118
118
|
activityId: string,
|
|
119
119
|
followeeApId: string,
|
|
120
|
-
|
|
120
|
+
startCursor: string | null = null,
|
|
121
|
+
): Promise<{
|
|
122
|
+
processed: number;
|
|
123
|
+
capped: boolean;
|
|
124
|
+
nextCursor: string | null;
|
|
125
|
+
}> {
|
|
121
126
|
// Page through accepted followers with a keyset cursor instead of loading
|
|
122
127
|
// every row into memory at once. Each page is planned and dispatched in
|
|
123
128
|
// ≤100-message chunks before the next page is read, bounding both memory
|
|
124
129
|
// and per-call batch size.
|
|
125
|
-
let cursor: string | null =
|
|
130
|
+
let cursor: string | null = startCursor;
|
|
126
131
|
let processed = 0;
|
|
127
132
|
let capped = false;
|
|
128
133
|
|
|
@@ -184,7 +189,7 @@ export async function enqueueFollowerEndpointDeliveries(
|
|
|
184
189
|
}
|
|
185
190
|
}
|
|
186
191
|
|
|
187
|
-
return { processed, capped };
|
|
192
|
+
return { processed, capped, nextCursor: capped ? cursor : null };
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
/**
|
|
@@ -215,21 +220,27 @@ export async function snapshotAndEnqueueFollowerDeliveries(
|
|
|
215
220
|
return;
|
|
216
221
|
}
|
|
217
222
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
let cursor: string | null = null;
|
|
224
|
+
let processed = 0;
|
|
225
|
+
do {
|
|
226
|
+
const page = await enqueueFollowerEndpointDeliveries(
|
|
227
|
+
db,
|
|
228
|
+
queue,
|
|
229
|
+
env.APP_URL,
|
|
230
|
+
activityId,
|
|
231
|
+
followeeApId,
|
|
232
|
+
cursor,
|
|
233
|
+
);
|
|
234
|
+
processed += page.processed;
|
|
235
|
+
cursor = page.nextCursor;
|
|
236
|
+
} while (cursor !== null);
|
|
225
237
|
|
|
226
|
-
if (
|
|
227
|
-
log.
|
|
228
|
-
event: "delivery.fanout.
|
|
238
|
+
if (processed > FANOUT_MAX_FOLLOWERS) {
|
|
239
|
+
log.info("Follower snapshot continued across bounded pages", {
|
|
240
|
+
event: "delivery.fanout.snapshot_continued",
|
|
229
241
|
followee: followeeApId,
|
|
230
242
|
activityId,
|
|
231
243
|
processed,
|
|
232
|
-
max: FANOUT_MAX_FOLLOWERS,
|
|
233
244
|
});
|
|
234
245
|
}
|
|
235
246
|
}
|
|
@@ -238,32 +249,38 @@ export async function processFanoutFollowers(
|
|
|
238
249
|
db: Database,
|
|
239
250
|
env: Env,
|
|
240
251
|
msg: DeliveryFanoutFollowersMessageV1,
|
|
241
|
-
message:
|
|
252
|
+
message: IQueueMessage<DeliveryQueueMessageV1>,
|
|
242
253
|
): Promise<void> {
|
|
243
254
|
if (!requireQueue(env, "fanout", message)) return;
|
|
244
255
|
const queueEnv = env as QueueEnv;
|
|
245
256
|
|
|
246
|
-
const { processed, capped } =
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
257
|
+
const { processed, capped, nextCursor } =
|
|
258
|
+
await enqueueFollowerEndpointDeliveries(
|
|
259
|
+
db,
|
|
260
|
+
queueEnv.DELIVERY_QUEUE,
|
|
261
|
+
env.APP_URL,
|
|
262
|
+
msg.activityId,
|
|
263
|
+
msg.followeeApId,
|
|
264
|
+
msg.cursor ?? null,
|
|
265
|
+
);
|
|
253
266
|
|
|
254
|
-
if (capped) {
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
267
|
+
if (capped && nextCursor !== null) {
|
|
268
|
+
// Send the continuation before ACK. If this send fails, Cloudflare retries
|
|
269
|
+
// the current message; deterministic endpoint job ids make that safe.
|
|
270
|
+
await sendQueueMessage(env, {
|
|
271
|
+
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
272
|
+
type: "fanout_followers",
|
|
273
|
+
activityId: msg.activityId,
|
|
274
|
+
followeeApId: msg.followeeApId,
|
|
275
|
+
cursor: nextCursor,
|
|
276
|
+
scheduledAt: nowIso(),
|
|
277
|
+
});
|
|
278
|
+
log.info("Follower fanout continued from stable cursor", {
|
|
279
|
+
event: "delivery.fanout.continued",
|
|
263
280
|
followee: msg.followeeApId,
|
|
264
281
|
activityId: msg.activityId,
|
|
265
282
|
processed,
|
|
266
|
-
|
|
283
|
+
cursor: nextCursor,
|
|
267
284
|
});
|
|
268
285
|
}
|
|
269
286
|
|
|
@@ -288,7 +305,7 @@ export async function processFanoutCommunity(
|
|
|
288
305
|
db: Database,
|
|
289
306
|
env: Env,
|
|
290
307
|
msg: DeliveryFanoutCommunityMessageV1,
|
|
291
|
-
message:
|
|
308
|
+
message: IQueueMessage<DeliveryQueueMessageV1>,
|
|
292
309
|
): Promise<void> {
|
|
293
310
|
const baseUrl = env.APP_URL;
|
|
294
311
|
|
|
@@ -456,7 +473,7 @@ export async function processResolveActor(
|
|
|
456
473
|
db: Database,
|
|
457
474
|
env: Env,
|
|
458
475
|
msg: DeliveryResolveActorMessageV1,
|
|
459
|
-
message:
|
|
476
|
+
message: IQueueMessage<DeliveryQueueMessageV1>,
|
|
460
477
|
): Promise<void> {
|
|
461
478
|
if (!requireQueue(env, "resolve_actor", message)) return;
|
|
462
479
|
|
|
@@ -554,7 +571,7 @@ export async function processReconcileJob(
|
|
|
554
571
|
db: Database,
|
|
555
572
|
env: Env,
|
|
556
573
|
msg: DeliveryReconcileJobMessageV1,
|
|
557
|
-
message:
|
|
574
|
+
message: IQueueMessage<DeliveryQueueMessageV1>,
|
|
558
575
|
): Promise<void> {
|
|
559
576
|
if (!requireQueue(env, "reconcile", message)) return;
|
|
560
577
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Includes signing, circuit breaker checks, retry logic, and dead-letter handling.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type { IQueueMessage } from "../../runtime/queue.ts";
|
|
7
7
|
import type { Env } from "../../types.ts";
|
|
8
8
|
import type { Database } from "../../../db/index.ts";
|
|
9
9
|
import { and, eq, lt, notInArray, or, sql } from "drizzle-orm";
|
|
@@ -186,7 +186,7 @@ async function failJob(
|
|
|
186
186
|
db: Database,
|
|
187
187
|
jobId: string,
|
|
188
188
|
error: string,
|
|
189
|
-
message:
|
|
189
|
+
message: IQueueMessage<DeliveryQueueMessageV1>,
|
|
190
190
|
): Promise<void> {
|
|
191
191
|
await db
|
|
192
192
|
.update(deliveryQueue)
|
|
@@ -286,7 +286,7 @@ export async function processDeliverEndpoint(
|
|
|
286
286
|
db: Database,
|
|
287
287
|
env: Env,
|
|
288
288
|
msg: DeliveryDeliverEndpointMessageV1,
|
|
289
|
-
message:
|
|
289
|
+
message: IQueueMessage<DeliveryQueueMessageV1>,
|
|
290
290
|
bulkhead: Bulkhead,
|
|
291
291
|
): Promise<void> {
|
|
292
292
|
if (!requireQueue(env, "deliver_endpoint", message)) return;
|