@takosjp/yurucommu-core 3.0.2 → 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 -17
- 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/stories.ts +16 -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 +51 -1
- 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/routes/stories/interactions.ts +65 -1
- package/src/backend/routes/stories/query-helpers.ts +48 -1
- package/src/backend/routes/stories/routes.ts +2 -30
- package/src/backend/server.ts +6 -0
- package/src/backend/types.ts +12 -0
- package/src/db/schema/mobile.ts +101 -1
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ActorStories,
|
|
3
|
+
Story,
|
|
4
|
+
StoryOverlay,
|
|
5
|
+
StoryViewersResponse,
|
|
6
|
+
} from "../../types/index.ts";
|
|
2
7
|
import { normalizeActorStories, normalizeStory } from "./normalize.ts";
|
|
3
8
|
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
4
9
|
|
|
@@ -78,3 +83,13 @@ export async function shareStory(
|
|
|
78
83
|
await assertOk(res, "Failed to share story");
|
|
79
84
|
return (await res.json()) as { shared: boolean; share_count: number };
|
|
80
85
|
}
|
|
86
|
+
|
|
87
|
+
// Author-only "seen by" list for a story. GETs the same id-encoded path the
|
|
88
|
+
// sibling like/share routes use.
|
|
89
|
+
export async function getStoryViewers(
|
|
90
|
+
apId: string,
|
|
91
|
+
): Promise<StoryViewersResponse> {
|
|
92
|
+
const res = await apiFetch(`/api/stories/${encodeURIComponent(apId)}/views`);
|
|
93
|
+
await assertOk(res, "Failed to fetch story viewers");
|
|
94
|
+
return (await res.json()) as StoryViewersResponse;
|
|
95
|
+
}
|
|
@@ -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"
|
|
@@ -150,7 +189,6 @@ export interface StoryOverlay {
|
|
|
150
189
|
// Question-specific
|
|
151
190
|
name?: string; // Question text
|
|
152
191
|
oneOf?: Array<{ type: string; name: string }>; // Options
|
|
153
|
-
closed?: string; // Close time
|
|
154
192
|
// Link-specific
|
|
155
193
|
href?: string;
|
|
156
194
|
// Generic
|
|
@@ -184,6 +222,18 @@ export interface ActorStories {
|
|
|
184
222
|
has_unviewed: boolean;
|
|
185
223
|
}
|
|
186
224
|
|
|
225
|
+
// A single viewer in a story's "seen by" list (author-only).
|
|
226
|
+
export interface StoryViewer {
|
|
227
|
+
actor: PostAuthor;
|
|
228
|
+
viewed_at: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Response of GET /api/stories/:id/views (author-only "seen by").
|
|
232
|
+
export interface StoryViewersResponse {
|
|
233
|
+
view_count: number;
|
|
234
|
+
viewers: StoryViewer[];
|
|
235
|
+
}
|
|
236
|
+
|
|
187
237
|
// Short-lived actor status note. This is the Instagram-Notes-style current
|
|
188
238
|
// status surface, not the ActivityPub `Note` object used for normal posts.
|
|
189
239
|
export interface ActorNote {
|
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
|
}
|