@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
|
Binary file
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
fetchUserInfo,
|
|
8
8
|
getAuthConfig,
|
|
9
9
|
getClientCredentials,
|
|
10
|
+
getMobileOidcAudience,
|
|
10
11
|
getProvider,
|
|
11
12
|
} from "../lib/oauth-providers.ts";
|
|
12
13
|
import { verifyOidcIdToken } from "../lib/oidc-id-token.ts";
|
|
@@ -296,7 +297,7 @@ auth.post("/mobile/oidc", async (c) => {
|
|
|
296
297
|
}
|
|
297
298
|
|
|
298
299
|
try {
|
|
299
|
-
const
|
|
300
|
+
const clientId = getMobileOidcAudience(c.env);
|
|
300
301
|
const claims = await verifyOidcIdToken(idToken, {
|
|
301
302
|
issuer: provider.issuer,
|
|
302
303
|
clientId,
|
|
@@ -23,6 +23,12 @@ import {
|
|
|
23
23
|
} from "../../lib/attachments.ts";
|
|
24
24
|
import { communityRequiresMembership } from "../../lib/community-visibility.ts";
|
|
25
25
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
26
|
+
import {
|
|
27
|
+
emitRealtimeBestEffort,
|
|
28
|
+
emitUnreadSnapshot,
|
|
29
|
+
isRealtimeAvailable,
|
|
30
|
+
runRealtimeAfterResponse,
|
|
31
|
+
} from "../../runtime/realtime-hub.ts";
|
|
26
32
|
import {
|
|
27
33
|
deleteObjectCascade,
|
|
28
34
|
purgeMediaBlobs,
|
|
@@ -408,24 +414,54 @@ messagesRouter.post(
|
|
|
408
414
|
...pushJobStatements,
|
|
409
415
|
]);
|
|
410
416
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
name: actor.name,
|
|
420
|
-
icon_url: actor.icon_url,
|
|
421
|
-
},
|
|
422
|
-
content,
|
|
423
|
-
attachments,
|
|
424
|
-
created_at: now,
|
|
425
|
-
},
|
|
417
|
+
const messagePayload = {
|
|
418
|
+
id: objectApId,
|
|
419
|
+
sender: {
|
|
420
|
+
ap_id: actor.ap_id,
|
|
421
|
+
username: formatUsername(actor.ap_id),
|
|
422
|
+
preferred_username: actor.preferred_username,
|
|
423
|
+
name: actor.name,
|
|
424
|
+
icon_url: actor.icon_url,
|
|
426
425
|
},
|
|
427
|
-
|
|
428
|
-
|
|
426
|
+
content,
|
|
427
|
+
attachments,
|
|
428
|
+
created_at: now,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Realtime fanout to LOCAL members (best-effort, after the response).
|
|
432
|
+
// Community talk has no inbox row, so the shared notification sweep never
|
|
433
|
+
// sees it — this direct emit is the only realtime path. Membership rows
|
|
434
|
+
// exist only for local members (remote membership is a follows edge), and
|
|
435
|
+
// the fanout is capped so a huge community cannot stall the writer.
|
|
436
|
+
if (isRealtimeAvailable(c.env)) {
|
|
437
|
+
const communityApIdForEmit = community.apId;
|
|
438
|
+
await runRealtimeAfterResponse(c, async () => {
|
|
439
|
+
const members = await db
|
|
440
|
+
.select({ actorApId: communityMembers.actorApId })
|
|
441
|
+
.from(communityMembers)
|
|
442
|
+
.where(eq(communityMembers.communityApId, communityApIdForEmit))
|
|
443
|
+
.limit(200);
|
|
444
|
+
await emitRealtimeBestEffort(
|
|
445
|
+
c.env,
|
|
446
|
+
members.map(({ actorApId }) => ({
|
|
447
|
+
actorApId,
|
|
448
|
+
type: "talk.message",
|
|
449
|
+
data: {
|
|
450
|
+
kind: "community",
|
|
451
|
+
community_ap_id: communityApIdForEmit,
|
|
452
|
+
message: messagePayload,
|
|
453
|
+
},
|
|
454
|
+
})),
|
|
455
|
+
);
|
|
456
|
+
await Promise.all(
|
|
457
|
+
members
|
|
458
|
+
.filter(({ actorApId }) => actorApId !== actor.ap_id)
|
|
459
|
+
.map(({ actorApId }) => emitUnreadSnapshot(c.env, actorApId)),
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return c.json({ message: messagePayload }, 201);
|
|
429
465
|
},
|
|
430
466
|
);
|
|
431
467
|
|
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
resolveConversationId,
|
|
36
36
|
} from "./query-helpers.ts";
|
|
37
37
|
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
38
|
+
import {
|
|
39
|
+
emitRealtimeBestEffort,
|
|
40
|
+
runRealtimeAfterResponse,
|
|
41
|
+
} from "../../runtime/realtime-hub.ts";
|
|
38
42
|
import { feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
39
43
|
import { toApAttachments } from "../../lib/activitypub-helpers.ts";
|
|
40
44
|
import { validateChatAttachments } from "../../lib/attachments.ts";
|
|
@@ -600,15 +604,51 @@ dm.post("/user/:encodedApId/messages", async (c) => {
|
|
|
600
604
|
await enqueueDeliveryToActor(c.env, deliveryActivityId, otherApId);
|
|
601
605
|
}
|
|
602
606
|
|
|
607
|
+
const messagePayload = {
|
|
608
|
+
id: apId,
|
|
609
|
+
sender: buildSenderFromActor(actor),
|
|
610
|
+
content,
|
|
611
|
+
attachments,
|
|
612
|
+
created_at: now,
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// Realtime fanout (best-effort, after the response): the recipient's open
|
|
616
|
+
// thread gets the message body without polling; the sender's OTHER tabs and
|
|
617
|
+
// devices stay in sync too. `other_ap_id` is per-recipient (each side sees
|
|
618
|
+
// the counterpart). Unread counters flow via the shared post-response sweep
|
|
619
|
+
// (the inbox trigger wrote a push job for the local recipient).
|
|
620
|
+
await runRealtimeAfterResponse(c, () =>
|
|
621
|
+
emitRealtimeBestEffort(c.env, [
|
|
622
|
+
...(isRecipientLocal
|
|
623
|
+
? [
|
|
624
|
+
{
|
|
625
|
+
actorApId: otherApId,
|
|
626
|
+
type: "talk.message",
|
|
627
|
+
data: {
|
|
628
|
+
kind: "dm",
|
|
629
|
+
other_ap_id: actor.ap_id,
|
|
630
|
+
conversation_id: conversationId,
|
|
631
|
+
message: messagePayload,
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
]
|
|
635
|
+
: []),
|
|
636
|
+
{
|
|
637
|
+
actorApId: actor.ap_id,
|
|
638
|
+
type: "talk.message",
|
|
639
|
+
data: {
|
|
640
|
+
kind: "dm",
|
|
641
|
+
other_ap_id: otherApId,
|
|
642
|
+
conversation_id: conversationId,
|
|
643
|
+
message: messagePayload,
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
]),
|
|
647
|
+
);
|
|
648
|
+
|
|
603
649
|
return c.json(
|
|
604
650
|
{
|
|
605
|
-
message:
|
|
606
|
-
id: apId,
|
|
607
|
-
sender: buildSenderFromActor(actor),
|
|
608
|
-
content,
|
|
609
|
-
attachments,
|
|
610
|
-
created_at: now,
|
|
611
|
-
},
|
|
651
|
+
message: messagePayload,
|
|
612
652
|
conversation_id: conversationId,
|
|
613
653
|
},
|
|
614
654
|
201,
|
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
objects,
|
|
12
12
|
} from "../../../db/index.ts";
|
|
13
13
|
import { resolveConversationId } from "./query-helpers.ts";
|
|
14
|
+
import {
|
|
15
|
+
emitRealtimeBestEffort,
|
|
16
|
+
emitUnreadSnapshot,
|
|
17
|
+
runRealtimeAfterResponse,
|
|
18
|
+
} from "../../runtime/realtime-hub.ts";
|
|
14
19
|
import {
|
|
15
20
|
buildActorInfoMap,
|
|
16
21
|
byTimeDesc,
|
|
@@ -54,6 +59,23 @@ readArchive.post("/user/:encodedApId/read", async (c) => {
|
|
|
54
59
|
set: { lastReadAt: now },
|
|
55
60
|
});
|
|
56
61
|
|
|
62
|
+
// Live read receipt for the partner's open thread + refreshed unread badge
|
|
63
|
+
// for the reader's OTHER tabs/devices.
|
|
64
|
+
await runRealtimeAfterResponse(c, async () => {
|
|
65
|
+
await emitRealtimeBestEffort(c.env, [
|
|
66
|
+
{
|
|
67
|
+
actorApId: otherApId,
|
|
68
|
+
type: "talk.read",
|
|
69
|
+
data: {
|
|
70
|
+
other_ap_id: actor.ap_id,
|
|
71
|
+
conversation_id: conversationId,
|
|
72
|
+
last_read_at: now,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
await emitUnreadSnapshot(c.env, actor.ap_id);
|
|
77
|
+
});
|
|
78
|
+
|
|
57
79
|
return c.json({ success: true, last_read_at: now });
|
|
58
80
|
});
|
|
59
81
|
|
|
@@ -108,6 +130,11 @@ readArchive.post("/community/:encodedApId/read", async (c) => {
|
|
|
108
130
|
set: { lastReadAt: now },
|
|
109
131
|
});
|
|
110
132
|
|
|
133
|
+
// Refresh the reader's unread badge on their other tabs/devices.
|
|
134
|
+
await runRealtimeAfterResponse(c, () =>
|
|
135
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
136
|
+
);
|
|
137
|
+
|
|
111
138
|
return c.json({ success: true, last_read_at: now });
|
|
112
139
|
});
|
|
113
140
|
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { and, eq } from "drizzle-orm";
|
|
5
5
|
import { dmTyping } from "../../../db/index.ts";
|
|
6
|
+
import {
|
|
7
|
+
emitRealtimeBestEffort,
|
|
8
|
+
runRealtimeAfterResponse,
|
|
9
|
+
} from "../../runtime/realtime-hub.ts";
|
|
6
10
|
import { type HonoEnv, parseOtherApId } from "./conversations-helpers.ts";
|
|
7
11
|
|
|
8
12
|
const typing = new Hono<HonoEnv>();
|
|
@@ -28,6 +32,18 @@ typing.post("/user/:encodedApId/typing", async (c) => {
|
|
|
28
32
|
set: { lastTypedAt: now },
|
|
29
33
|
});
|
|
30
34
|
|
|
35
|
+
// Push the indicator to the partner's live sockets; the GET endpoint stays
|
|
36
|
+
// as the fallback-polling read.
|
|
37
|
+
await runRealtimeAfterResponse(c, () =>
|
|
38
|
+
emitRealtimeBestEffort(c.env, [
|
|
39
|
+
{
|
|
40
|
+
actorApId: otherApId,
|
|
41
|
+
type: "talk.typing",
|
|
42
|
+
data: { other_ap_id: actor.ap_id, is_typing: true, typed_at: now },
|
|
43
|
+
},
|
|
44
|
+
]),
|
|
45
|
+
);
|
|
46
|
+
|
|
31
47
|
return c.json({ success: true, typed_at: now });
|
|
32
48
|
});
|
|
33
49
|
|
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
NOTIFICATION_ACTIVITY_TYPES,
|
|
36
36
|
notificationEligibilityWhere,
|
|
37
37
|
} from "../lib/notification-eligibility.ts";
|
|
38
|
+
import {
|
|
39
|
+
emitUnreadSnapshot,
|
|
40
|
+
runRealtimeAfterResponse,
|
|
41
|
+
} from "../runtime/realtime-hub.ts";
|
|
38
42
|
|
|
39
43
|
const notifications = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
40
44
|
|
|
@@ -622,6 +626,11 @@ notifications.post("/read", async (c) => {
|
|
|
622
626
|
);
|
|
623
627
|
}
|
|
624
628
|
|
|
629
|
+
// Sync the reader's OTHER tabs/devices: push the fresh authoritative badge.
|
|
630
|
+
await runRealtimeAfterResponse(c, () =>
|
|
631
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
632
|
+
);
|
|
633
|
+
|
|
625
634
|
return c.json({ success: true });
|
|
626
635
|
});
|
|
627
636
|
|
|
@@ -681,6 +690,11 @@ notifications.post("/archive", async (c) => {
|
|
|
681
690
|
ARCHIVE_CREATE_BATCH_SIZE,
|
|
682
691
|
);
|
|
683
692
|
|
|
693
|
+
// Archiving an unread notification removes it from the badge count.
|
|
694
|
+
await runRealtimeAfterResponse(c, () =>
|
|
695
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
696
|
+
);
|
|
697
|
+
|
|
684
698
|
return c.json({ success: true, archived_count });
|
|
685
699
|
});
|
|
686
700
|
|
|
@@ -716,6 +730,11 @@ notifications.delete("/archive", async (c) => {
|
|
|
716
730
|
),
|
|
717
731
|
);
|
|
718
732
|
|
|
733
|
+
// Unarchiving can resurface unread rows into the badge count.
|
|
734
|
+
await runRealtimeAfterResponse(c, () =>
|
|
735
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
736
|
+
);
|
|
737
|
+
|
|
719
738
|
return c.json({ success: true });
|
|
720
739
|
});
|
|
721
740
|
|
|
@@ -758,6 +777,12 @@ notifications.post("/archive/all", async (c) => {
|
|
|
758
777
|
rows,
|
|
759
778
|
ARCHIVE_CREATE_BATCH_SIZE,
|
|
760
779
|
);
|
|
780
|
+
|
|
781
|
+
// Archive-all clears the whole badge; sync the reader's other tabs/devices.
|
|
782
|
+
await runRealtimeAfterResponse(c, () =>
|
|
783
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
784
|
+
);
|
|
785
|
+
|
|
761
786
|
return c.json({ success: true, archived_count });
|
|
762
787
|
});
|
|
763
788
|
|
|
@@ -15,7 +15,10 @@ import {
|
|
|
15
15
|
communities,
|
|
16
16
|
communityMembers,
|
|
17
17
|
inbox as inboxTable,
|
|
18
|
+
insertMany,
|
|
18
19
|
objects,
|
|
20
|
+
runBatch,
|
|
21
|
+
type D1Statement,
|
|
19
22
|
} from "../../../db/index.ts";
|
|
20
23
|
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
|
21
24
|
import type { Database } from "../../../db/index.ts";
|
|
@@ -666,33 +669,49 @@ export async function processMentions(
|
|
|
666
669
|
}
|
|
667
670
|
|
|
668
671
|
if (activitiesToCreate.length > 0) {
|
|
672
|
+
// A mention notification is one invariant: its activity and inbox edge
|
|
673
|
+
// either both exist or neither does. Build D1-safe chunked INSERT
|
|
674
|
+
// statements, then commit each bounded page as one atomic batch. This
|
|
675
|
+
// avoids both the 100-bind ceiling and the old activity-without-inbox
|
|
676
|
+
// partial state when the second independent INSERT failed.
|
|
677
|
+
const MENTION_NOTIFICATION_PAGE_SIZE = 200;
|
|
669
678
|
try {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
679
|
+
for (
|
|
680
|
+
let offset = 0;
|
|
681
|
+
offset < activitiesToCreate.length;
|
|
682
|
+
offset += MENTION_NOTIFICATION_PAGE_SIZE
|
|
683
|
+
) {
|
|
684
|
+
const activityPage = activitiesToCreate.slice(
|
|
685
|
+
offset,
|
|
686
|
+
offset + MENTION_NOTIFICATION_PAGE_SIZE,
|
|
687
|
+
);
|
|
688
|
+
const inboxPage = inboxEntriesToCreate.slice(
|
|
689
|
+
offset,
|
|
690
|
+
offset + MENTION_NOTIFICATION_PAGE_SIZE,
|
|
691
|
+
);
|
|
692
|
+
const statements = [
|
|
693
|
+
...insertMany(db, activities, activityPage),
|
|
694
|
+
...insertMany(db, inboxTable, inboxPage),
|
|
695
|
+
];
|
|
696
|
+
await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
|
|
697
|
+
}
|
|
686
698
|
} catch (e) {
|
|
687
|
-
log.error("Failed to persist mention
|
|
688
|
-
event: "posts.mention.
|
|
699
|
+
log.error("Failed to atomically persist mention notifications", {
|
|
700
|
+
event: "posts.mention.notification_persist_failed",
|
|
689
701
|
error: e,
|
|
690
702
|
});
|
|
691
|
-
mentionFailures.push(
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
703
|
+
mentionFailures.push(
|
|
704
|
+
{
|
|
705
|
+
mention: "__batch__",
|
|
706
|
+
stage: "persist_activity",
|
|
707
|
+
reason: "mention_activity_persist_failed",
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
mention: "__batch__",
|
|
711
|
+
stage: "persist_inbox",
|
|
712
|
+
reason: "mention_inbox_persist_failed",
|
|
713
|
+
},
|
|
714
|
+
);
|
|
696
715
|
}
|
|
697
716
|
}
|
|
698
717
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime stream routes.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/realtime/config capability probe ({ available }) — clients
|
|
5
|
+
* gate their fallback polling on this
|
|
6
|
+
* POST /api/realtime/ticket mint a one-time short-lived WS ticket
|
|
7
|
+
* GET /api/realtime/socket browser WebSocket upgrade -> per-user stream
|
|
8
|
+
*
|
|
9
|
+
* Two upgrade auth paths, both terminating in the worker BEFORE the DO is
|
|
10
|
+
* reached (the DO binding is the trust boundary):
|
|
11
|
+
* - session: the same-origin browser sends its session cookie; the /api/*
|
|
12
|
+
* middleware resolved the actor already.
|
|
13
|
+
* - ticket: a cross-origin or bearer-auth client (the browser WebSocket API
|
|
14
|
+
* cannot set an Authorization header) first POSTs /ticket over the normal
|
|
15
|
+
* authenticated fetch path, then connects with ?actor=&ticket=. The ticket
|
|
16
|
+
* is minted inside — and re-verified + consumed by — the target user's own
|
|
17
|
+
* stream DO, so it is single-use, expires in ~60s, and never carries the
|
|
18
|
+
* raw session credential in a URL.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Hono } from "hono";
|
|
22
|
+
import type { Env, Variables } from "../../types.ts";
|
|
23
|
+
import {
|
|
24
|
+
getRealtimeHub,
|
|
25
|
+
isRealtimeAvailable,
|
|
26
|
+
} from "../../runtime/realtime-hub.ts";
|
|
27
|
+
|
|
28
|
+
const realtime = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
29
|
+
|
|
30
|
+
realtime.get("/config", (c) => {
|
|
31
|
+
return c.json({ available: isRealtimeAvailable(c.env) });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
realtime.post("/ticket", async (c) => {
|
|
35
|
+
const actor = c.get("actor");
|
|
36
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
37
|
+
if (!isRealtimeAvailable(c.env)) {
|
|
38
|
+
return c.json({ error: "realtime_unavailable" }, 503);
|
|
39
|
+
}
|
|
40
|
+
const ticket = await getRealtimeHub(c.env).mintTicket(actor.ap_id);
|
|
41
|
+
if (!ticket) return c.json({ error: "ticket_mint_failed" }, 500);
|
|
42
|
+
return c.json({ ticket, actor_ap_id: actor.ap_id });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
realtime.get("/socket", async (c) => {
|
|
46
|
+
if (!isRealtimeAvailable(c.env)) {
|
|
47
|
+
return c.json({ error: "realtime_unavailable" }, 503);
|
|
48
|
+
}
|
|
49
|
+
const hub = getRealtimeHub(c.env);
|
|
50
|
+
|
|
51
|
+
const sessionActor = c.get("actor");
|
|
52
|
+
if (sessionActor) {
|
|
53
|
+
return hub.upgrade(c.req.raw, sessionActor.ap_id, "session");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const actorParam = c.req.query("actor")?.trim();
|
|
57
|
+
const ticket = c.req.query("ticket")?.trim();
|
|
58
|
+
if (actorParam && ticket) {
|
|
59
|
+
// The actor param only selects WHICH stream DO verifies the ticket; a
|
|
60
|
+
// forged actor value fails inside that DO (it never minted the ticket).
|
|
61
|
+
return hub.upgrade(c.req.raw, actorParam, "ticket", ticket);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default realtime;
|
|
@@ -139,7 +139,11 @@ rtc.get("/api/rtc/calls", async (c) => {
|
|
|
139
139
|
rtc.get("/api/rtc/calls/:id", async (c) => {
|
|
140
140
|
const actor = c.get("actor");
|
|
141
141
|
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
142
|
-
const call = await getCallSession(
|
|
142
|
+
const call = await getCallSession(
|
|
143
|
+
c.get("db"),
|
|
144
|
+
actor.ap_id,
|
|
145
|
+
c.req.param("id"),
|
|
146
|
+
);
|
|
143
147
|
if (!call) return c.json({ error: "not_found" }, 404);
|
|
144
148
|
return c.json({ call });
|
|
145
149
|
});
|
|
@@ -98,7 +98,9 @@ export class CallHub {
|
|
|
98
98
|
|
|
99
99
|
/** Snapshot of active (non-terminal) calls — used by the DO to persist. */
|
|
100
100
|
activeCalls(): CallRecord[] {
|
|
101
|
-
return [...this.calls.values()].filter(
|
|
101
|
+
return [...this.calls.values()].filter(
|
|
102
|
+
(c) => !isTerminalCallState(c.state),
|
|
103
|
+
);
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/** Restore calls from durable storage after DO hibernation. */
|
|
@@ -175,7 +177,11 @@ export class CallHub {
|
|
|
175
177
|
conn.send({ t: "ready" });
|
|
176
178
|
// Re-announce any active calls so a reconnecting tab resyncs.
|
|
177
179
|
for (const call of this.activeCalls()) {
|
|
178
|
-
conn.send({
|
|
180
|
+
conn.send({
|
|
181
|
+
t: "call-state",
|
|
182
|
+
callId: call.callId,
|
|
183
|
+
state: call.state,
|
|
184
|
+
});
|
|
179
185
|
}
|
|
180
186
|
return;
|
|
181
187
|
case "ping":
|
|
@@ -401,7 +407,11 @@ export class CallHub {
|
|
|
401
407
|
const call = this.calls.get(envelope.callId);
|
|
402
408
|
if (!call || !envelope.sdp) return;
|
|
403
409
|
if (call.state === "ringing") this.transition(call, "connecting");
|
|
404
|
-
this.port.broadcast({
|
|
410
|
+
this.port.broadcast({
|
|
411
|
+
t: "answer",
|
|
412
|
+
callId: call.callId,
|
|
413
|
+
sdp: envelope.sdp,
|
|
414
|
+
});
|
|
405
415
|
}
|
|
406
416
|
|
|
407
417
|
private onPeerCandidate(envelope: RtcSignalEnvelopeV1): void {
|
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
D1Database,
|
|
9
9
|
Fetcher,
|
|
10
10
|
KVNamespace,
|
|
11
|
+
MessageBatch,
|
|
12
|
+
Queue,
|
|
11
13
|
R2Bucket,
|
|
12
14
|
R2Object,
|
|
13
15
|
} from "@cloudflare/workers-types";
|
|
@@ -20,6 +22,13 @@ import type {
|
|
|
20
22
|
ObjectMetadata,
|
|
21
23
|
StorageObject,
|
|
22
24
|
} from "./types.ts";
|
|
25
|
+
import type {
|
|
26
|
+
IQueueBatch,
|
|
27
|
+
IQueueMessage,
|
|
28
|
+
IQueueProducer,
|
|
29
|
+
QueueBatchItem,
|
|
30
|
+
QueueSendOptions,
|
|
31
|
+
} from "./queue.ts";
|
|
23
32
|
|
|
24
33
|
/**
|
|
25
34
|
* Cloudflare R2 Storage Adapter
|
|
@@ -170,6 +179,46 @@ class CloudflareAssets implements IStaticAssets {
|
|
|
170
179
|
}
|
|
171
180
|
}
|
|
172
181
|
|
|
182
|
+
class CloudflareQueueProducer<T> implements IQueueProducer<T> {
|
|
183
|
+
constructor(private readonly queue: Queue<T>) {}
|
|
184
|
+
|
|
185
|
+
async send(body: T, options?: QueueSendOptions): Promise<void> {
|
|
186
|
+
await this.queue.send(body, options);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async sendBatch(
|
|
190
|
+
messages: readonly QueueBatchItem<T>[],
|
|
191
|
+
options?: QueueSendOptions,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
await this.queue.sendBatch([...messages], options);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function wrapCloudflareQueue<T>(queue: Queue<T>): IQueueProducer<T> {
|
|
198
|
+
return new CloudflareQueueProducer(queue);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function wrapCloudflareMessageBatch<T>(
|
|
202
|
+
batch: MessageBatch<T>,
|
|
203
|
+
): IQueueBatch<T> {
|
|
204
|
+
const messages: readonly IQueueMessage<T>[] = batch.messages.map(
|
|
205
|
+
(message) => ({
|
|
206
|
+
id: message.id,
|
|
207
|
+
timestamp: message.timestamp,
|
|
208
|
+
body: message.body,
|
|
209
|
+
attempts: message.attempts,
|
|
210
|
+
ack: () => message.ack(),
|
|
211
|
+
retry: (options) => message.retry(options),
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
return {
|
|
215
|
+
queue: batch.queue,
|
|
216
|
+
messages,
|
|
217
|
+
ackAll: () => batch.ackAll(),
|
|
218
|
+
retryAll: (options) => batch.retryAll(options),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
173
222
|
/**
|
|
174
223
|
* Wrap a native Cloudflare Workers binding env into the app's runtime
|
|
175
224
|
* `Env` shape. The Hono app and all helper functions speak the runtime
|
|
@@ -182,21 +231,33 @@ export function wrapCloudflareBindings<
|
|
|
182
231
|
MEDIA?: R2Bucket;
|
|
183
232
|
KV: KVNamespace;
|
|
184
233
|
ASSETS?: Fetcher;
|
|
234
|
+
DELIVERY_QUEUE?: Queue<unknown>;
|
|
235
|
+
DELIVERY_DLQ?: Queue<unknown>;
|
|
185
236
|
},
|
|
186
237
|
>(
|
|
187
238
|
bindings: T,
|
|
188
|
-
): Omit<
|
|
239
|
+
): Omit<
|
|
240
|
+
T,
|
|
241
|
+
"DB" | "MEDIA" | "KV" | "ASSETS" | "DELIVERY_QUEUE" | "DELIVERY_DLQ"
|
|
242
|
+
> & {
|
|
189
243
|
DB_INSTANCE: ReturnType<typeof getDb>;
|
|
190
244
|
MEDIA?: IObjectStorage;
|
|
191
245
|
KV: IKeyValueStore;
|
|
192
246
|
ASSETS?: IStaticAssets;
|
|
247
|
+
DELIVERY_QUEUE?: IQueueProducer<unknown>;
|
|
248
|
+
DELIVERY_DLQ?: IQueueProducer<unknown>;
|
|
193
249
|
} {
|
|
194
|
-
const { DB, MEDIA, KV, ASSETS, ...rest } =
|
|
250
|
+
const { DB, MEDIA, KV, ASSETS, DELIVERY_QUEUE, DELIVERY_DLQ, ...rest } =
|
|
251
|
+
bindings;
|
|
195
252
|
return {
|
|
196
253
|
...rest,
|
|
197
254
|
DB_INSTANCE: getDb(DB),
|
|
198
255
|
MEDIA: MEDIA ? new CloudflareStorage(MEDIA) : undefined,
|
|
199
256
|
KV: new CloudflareKV(KV),
|
|
200
257
|
ASSETS: ASSETS ? new CloudflareAssets(ASSETS) : undefined,
|
|
258
|
+
DELIVERY_QUEUE: DELIVERY_QUEUE
|
|
259
|
+
? wrapCloudflareQueue(DELIVERY_QUEUE)
|
|
260
|
+
: undefined,
|
|
261
|
+
DELIVERY_DLQ: DELIVERY_DLQ ? wrapCloudflareQueue(DELIVERY_DLQ) : undefined,
|
|
201
262
|
};
|
|
202
263
|
}
|