@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.
- package/README.en.md +92 -0
- package/README.md +56 -47
- package/migrations/0019_notification_push_delivery.sql +100 -0
- package/migrations/README.md +7 -6
- package/package.json +7 -4
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/browser-push.ts +534 -0
- package/packages/api/src/lib/api/communities.ts +25 -2
- package/packages/api/src/lib/api/dm.ts +14 -2
- package/packages/api/src/lib/api/notifications.ts +53 -1
- package/packages/api/src/lib/api.ts +1 -0
- package/packages/api/src/social-server.ts +2 -0
- package/packages/api/src/types/index.ts +39 -0
- package/src/backend/index.ts +29 -0
- package/src/backend/lib/delivery/queue.ts +18 -0
- package/src/backend/lib/delivery/types.ts +15 -1
- package/src/backend/lib/notification-push.ts +1200 -0
- package/src/backend/lib/notification-pusher-contract.ts +340 -0
- package/src/backend/lib/oauth-providers.ts +7 -6
- package/src/backend/routes/communities/messages.ts +140 -9
- package/src/backend/routes/dm/messages.ts +85 -4
- package/src/backend/routes/notification-pushers.ts +93 -0
- package/src/backend/routes/notifications.ts +50 -0
- package/src/backend/server.ts +6 -0
- package/src/backend/types.ts +12 -0
- package/src/db/schema/mobile.ts +101 -1
|
@@ -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"
|
package/src/backend/index.ts
CHANGED
|
@@ -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
|
}
|