@takosjp/yurucommu-core 3.0.3 → 3.2.0

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.
@@ -7,6 +7,7 @@ export * from "./api/posts.ts";
7
7
  export * from "./api/communities.ts";
8
8
  export * from "./api/dm.ts";
9
9
  export * from "./api/notifications.ts";
10
+ export * from "./api/browser-push.ts";
10
11
  export * from "./api/search.ts";
11
12
  export * from "./api/media.ts";
12
13
  export * from "./api/stories.ts";
@@ -28,6 +28,8 @@ export interface SocialServerDiscovery {
28
28
  readonly timeline: string;
29
29
  readonly conversations: string;
30
30
  readonly notifications: string;
31
+ readonly notificationPushers: string;
32
+ /** @deprecated Use notificationPushers. */
31
33
  readonly mobilePushRegistrations: string;
32
34
  };
33
35
  }
@@ -102,6 +102,8 @@ export interface DMMessage {
102
102
  id: string;
103
103
  sender: DMSender;
104
104
  content: string;
105
+ /** Media attachments (image/video), same shape as post attachments. */
106
+ attachments?: MediaAttachment[];
105
107
  created_at: string;
106
108
  }
107
109
 
@@ -120,10 +122,47 @@ export interface Notification {
120
122
  type: "follow" | "follow_request" | "like" | "announce" | "reply" | "mention";
121
123
  actor: NotificationActor;
122
124
  object_ap_id: string | null;
125
+ target_kind: "post" | "story" | "profile" | "community" | "notifications";
126
+ target_id: string | null;
127
+ /** Same-origin in-app path. Clients must not treat this as an external URL. */
128
+ target_url: string;
123
129
  read: boolean;
124
130
  created_at: string;
125
131
  }
126
132
 
133
+ export type NotificationPusherProduct = "yurucommu" | "yurume";
134
+
135
+ export interface NotificationPusherInput {
136
+ kind: "http";
137
+ app_id: string;
138
+ pushkey: string;
139
+ app_display_name?: string;
140
+ device_display_name?: string;
141
+ profile_tag?: string;
142
+ lang?: string;
143
+ data: {
144
+ url: string;
145
+ format?: "event_id_only" | "full";
146
+ [key: string]: unknown;
147
+ };
148
+ }
149
+
150
+ export interface NotificationPusherRegistration {
151
+ id: string;
152
+ kind: "http";
153
+ app_id: string;
154
+ app_display_name?: string;
155
+ device_display_name?: string;
156
+ profile_tag?: string;
157
+ lang?: string;
158
+ data: Record<string, unknown>;
159
+ gateway_url: string;
160
+ product: NotificationPusherProduct;
161
+ scope: string | null;
162
+ registered_at: string;
163
+ last_seen_at: string;
164
+ }
165
+
127
166
  // Story attachment (image or video)
128
167
  export interface StoryAttachment {
129
168
  type: string; // "Document" or "Video"
@@ -1,5 +1,6 @@
1
1
  import { Hono, type Context } from "hono";
2
2
  import { MOBILE_PUSH_REGISTRATION_PATH } from "./lib/mobile-contract.ts";
3
+ import { NOTIFICATION_PUSHER_REGISTRATION_PATH } from "./lib/notification-pusher-contract.ts";
3
4
  import type { Env, EnvVars, Variables } from "./types.ts";
4
5
  import { extractActorFromSession } from "./lib/session-actor.ts";
5
6
  import { isBackendPath } from "./lib/backend-paths.ts";
@@ -27,6 +28,7 @@ import recommendationsRoutes from "./routes/recommendations.ts";
27
28
  import { moderationRoutes } from "./routes/moderation.ts";
28
29
  import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
29
30
  import mobileRoutes from "./routes/mobile.ts";
31
+ import notificationPusherRoutes from "./routes/notification-pushers.ts";
30
32
 
31
33
  import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
32
34
  import { csrfProtection } from "./middleware/csrf.ts";
@@ -38,6 +40,7 @@ import {
38
40
  import { logger } from "./lib/logger.ts";
39
41
 
40
42
  const log = logger.child({ component: "backend.index" });
43
+ let lastNotificationPushRecoverySweep = 0;
41
44
  import type { MessageBatch } from "@cloudflare/workers-types";
42
45
  import type {
43
46
  DeliveryDlqMessageV1,
@@ -47,6 +50,7 @@ import {
47
50
  handleDeliveryDlqBatch,
48
51
  handleDeliveryQueueBatch,
49
52
  } from "./lib/delivery/queue.ts";
53
+ import { enqueuePendingNotificationPushJobs } from "./lib/notification-push.ts";
50
54
 
51
55
  type YurucommuApp = Hono<{ Bindings: Env; Variables: Variables }>;
52
56
 
@@ -106,6 +110,7 @@ const DEFAULT_DISCOVERY_OPTIONS = {
106
110
  "activitypub.server.v1",
107
111
  "client.yurucommu.feed.v1",
108
112
  "client.yurume.messages.v1",
113
+ "notification.pushers.v1",
109
114
  ],
110
115
  } satisfies Required<YurucommuBackendDiscoveryOptionsV1>;
111
116
 
@@ -245,6 +250,8 @@ function buildSocialServerDiscovery(
245
250
  timeline: `${appUrl}/api/timeline`,
246
251
  conversations: `${appUrl}/api/dm/contacts`,
247
252
  notifications: `${appUrl}/api/notifications`,
253
+ notificationPushers: `${appUrl}${NOTIFICATION_PUSHER_REGISTRATION_PATH}`,
254
+ // Retained for older mobile clients. New clients use notificationPushers.
248
255
  mobilePushRegistrations: `${appUrl}${MOBILE_PUSH_REGISTRATION_PATH}`,
249
256
  },
250
257
  };
@@ -545,6 +552,27 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
545
552
  app.use("*", async (c, next) => {
546
553
  c.set("db", c.env.DB_INSTANCE);
547
554
  await next();
555
+
556
+ // Every unread inbox insert is captured by the DB outbox trigger. Flush it
557
+ // after the request instead of wiring every follow/like/story/DM write path
558
+ // separately (which is both leak- and duplicate-prone). Queue binding is
559
+ // optional; when absent this is an immediate no-op and the durable rows stay
560
+ // pending until a correctly configured runtime handles later traffic.
561
+ const method = c.req.method.toUpperCase();
562
+ const now = Date.now();
563
+ const mutating = !["GET", "HEAD", "OPTIONS"].includes(method);
564
+ const recoveryDue = now - lastNotificationPushRecoverySweep >= 60_000;
565
+ if (mutating || recoveryDue) {
566
+ if (recoveryDue) lastNotificationPushRecoverySweep = now;
567
+ try {
568
+ await enqueuePendingNotificationPushJobs(c.env);
569
+ } catch (error) {
570
+ log.error("Failed to enqueue notification push outbox", {
571
+ event: "notification.push.enqueue_failed",
572
+ error,
573
+ });
574
+ }
575
+ }
548
576
  });
549
577
 
550
578
  app.use("/api/*", async (c, next) => {
@@ -690,6 +718,7 @@ function mountCoreRoutes(app: YurucommuApp): void {
690
718
  });
691
719
 
692
720
  app.route("/api/notifications", notificationsRoutes);
721
+ app.route("/api/notifications/pushers", notificationPusherRoutes);
693
722
  app.route("/api/mobile", mobileRoutes);
694
723
  app.route("/api/stories", storiesRoutes);
695
724
  app.route("/api/search", searchRoutes);
@@ -21,6 +21,10 @@ import { computeDeliveryJobId, safeEndpointHost } from "./transformers.ts";
21
21
  import { emitMetric } from "./metrics.ts";
22
22
  import { logger } from "../logger.ts";
23
23
  import { filterBlockedActorApIds, isActorBlocked } from "../blocklist.ts";
24
+ import {
25
+ enqueuePendingNotificationPushJobs,
26
+ processNotificationPushJob,
27
+ } from "../notification-push.ts";
24
28
 
25
29
  const log = logger.child({ component: "delivery.queue" });
26
30
 
@@ -471,6 +475,9 @@ export async function handleDeliveryQueueBatch(
471
475
  case "reconcile_job":
472
476
  await processReconcileJob(db, env, body, message);
473
477
  break;
478
+ case "notification_push":
479
+ await processNotificationPushJob(env, body, message);
480
+ break;
474
481
  default:
475
482
  assertNever(body);
476
483
  }
@@ -512,6 +519,17 @@ export async function handleDeliveryQueueBatch(
512
519
  }
513
520
  },
514
521
  );
522
+
523
+ // Community fanout can create local inbox rows inside this consumer rather
524
+ // than an HTTP request. Flush the same DB-triggered outbox choke point here.
525
+ try {
526
+ await enqueuePendingNotificationPushJobs(env);
527
+ } catch (error) {
528
+ log.error("Failed to enqueue notification push outbox", {
529
+ event: "notification.push.enqueue_failed",
530
+ error,
531
+ });
532
+ }
515
533
  }
516
534
 
517
535
  export async function handleDeliveryDlqBatch(
@@ -60,12 +60,24 @@ export type DeliveryReconcileJobMessageV1 = {
60
60
  scheduledAt: string; // ISO8601 UTC
61
61
  };
62
62
 
63
+ /**
64
+ * Product notification delivery. The queue carries only the durable outbox id;
65
+ * device pushkeys, gateway URLs, and notification content remain in the DB.
66
+ */
67
+ export type DeliveryNotificationPushMessageV1 = {
68
+ version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
69
+ type: "notification_push";
70
+ jobId: string;
71
+ scheduledAt: string; // ISO8601 UTC
72
+ };
73
+
63
74
  export type DeliveryQueueMessageV1 =
64
75
  | DeliveryFanoutFollowersMessageV1
65
76
  | DeliveryFanoutCommunityMessageV1
66
77
  | DeliveryResolveActorMessageV1
67
78
  | DeliveryDeliverEndpointMessageV1
68
- | DeliveryReconcileJobMessageV1;
79
+ | DeliveryReconcileJobMessageV1
80
+ | DeliveryNotificationPushMessageV1;
69
81
 
70
82
  export type DeliveryDlqMessageV1 = {
71
83
  version: typeof DELIVERY_QUEUE_MESSAGE_VERSION;
@@ -116,6 +128,8 @@ export function isDeliveryQueueMessageV1(
116
128
  typeof v.reconcileAttempt === "number" &&
117
129
  typeof v.scheduledAt === "string"
118
130
  );
131
+ case "notification_push":
132
+ return typeof v.jobId === "string" && typeof v.scheduledAt === "string";
119
133
  default:
120
134
  return false;
121
135
  }