@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
@@ -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 { clientId } = getClientCredentials(c.env, "takos");
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
- return c.json(
412
- {
413
- message: {
414
- id: objectApId,
415
- sender: {
416
- ap_id: actor.ap_id,
417
- username: formatUsername(actor.ap_id),
418
- preferred_username: actor.preferred_username,
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
- 201,
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
- await db.insert(activities).values(activitiesToCreate);
671
- } catch (e) {
672
- log.error("Failed to persist mention activities", {
673
- event: "posts.mention.activity_persist_failed",
674
- error: e,
675
- });
676
- mentionFailures.push({
677
- mention: "__batch__",
678
- stage: "persist_activity",
679
- reason: "mention_activity_persist_failed",
680
- });
681
- }
682
- }
683
- if (inboxEntriesToCreate.length > 0) {
684
- try {
685
- await db.insert(inboxTable).values(inboxEntriesToCreate);
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 inbox entries", {
688
- event: "posts.mention.inbox_persist_failed",
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
- mention: "__batch__",
693
- stage: "persist_inbox",
694
- reason: "mention_inbox_persist_failed",
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(c.get("db"), actor.ap_id, c.req.param("id"));
142
+ const call = await getCallSession(
143
+ c.get("db"),
144
+ actor.ap_id,
145
+ c.req.param("id"),
146
+ );
143
147
  if (!call) return c.json({ error: "not_found" }, 404);
144
148
  return c.json({ call });
145
149
  });
@@ -98,7 +98,9 @@ export class CallHub {
98
98
 
99
99
  /** Snapshot of active (non-terminal) calls — used by the DO to persist. */
100
100
  activeCalls(): CallRecord[] {
101
- return [...this.calls.values()].filter((c) => !isTerminalCallState(c.state));
101
+ return [...this.calls.values()].filter(
102
+ (c) => !isTerminalCallState(c.state),
103
+ );
102
104
  }
103
105
 
104
106
  /** Restore calls from durable storage after DO hibernation. */
@@ -175,7 +177,11 @@ export class CallHub {
175
177
  conn.send({ t: "ready" });
176
178
  // Re-announce any active calls so a reconnecting tab resyncs.
177
179
  for (const call of this.activeCalls()) {
178
- conn.send({ t: "call-state", callId: call.callId, state: call.state });
180
+ conn.send({
181
+ t: "call-state",
182
+ callId: call.callId,
183
+ state: call.state,
184
+ });
179
185
  }
180
186
  return;
181
187
  case "ping":
@@ -401,7 +407,11 @@ export class CallHub {
401
407
  const call = this.calls.get(envelope.callId);
402
408
  if (!call || !envelope.sdp) return;
403
409
  if (call.state === "ringing") this.transition(call, "connecting");
404
- this.port.broadcast({ t: "answer", callId: call.callId, sdp: envelope.sdp });
410
+ this.port.broadcast({
411
+ t: "answer",
412
+ callId: call.callId,
413
+ sdp: envelope.sdp,
414
+ });
405
415
  }
406
416
 
407
417
  private onPeerCandidate(envelope: RtcSignalEnvelopeV1): void {
@@ -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<T, "DB" | "MEDIA" | "KV" | "ASSETS"> & {
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 } = bindings;
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
  }