@rmdes/indiekit-endpoint-microsub 1.0.0-beta.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/README.md +111 -0
  2. package/index.js +140 -0
  3. package/lib/cache/redis.js +133 -0
  4. package/lib/controllers/block.js +85 -0
  5. package/lib/controllers/channels.js +135 -0
  6. package/lib/controllers/events.js +56 -0
  7. package/lib/controllers/follow.js +108 -0
  8. package/lib/controllers/microsub.js +138 -0
  9. package/lib/controllers/mute.js +124 -0
  10. package/lib/controllers/preview.js +67 -0
  11. package/lib/controllers/reader.js +218 -0
  12. package/lib/controllers/search.js +142 -0
  13. package/lib/controllers/timeline.js +117 -0
  14. package/lib/feeds/atom.js +61 -0
  15. package/lib/feeds/fetcher.js +205 -0
  16. package/lib/feeds/hfeed.js +177 -0
  17. package/lib/feeds/jsonfeed.js +43 -0
  18. package/lib/feeds/normalizer.js +586 -0
  19. package/lib/feeds/parser.js +124 -0
  20. package/lib/feeds/rss.js +61 -0
  21. package/lib/polling/processor.js +201 -0
  22. package/lib/polling/scheduler.js +128 -0
  23. package/lib/polling/tier.js +139 -0
  24. package/lib/realtime/broker.js +241 -0
  25. package/lib/search/indexer.js +90 -0
  26. package/lib/search/query.js +197 -0
  27. package/lib/storage/channels.js +281 -0
  28. package/lib/storage/feeds.js +286 -0
  29. package/lib/storage/filters.js +265 -0
  30. package/lib/storage/items.js +419 -0
  31. package/lib/storage/read-state.js +109 -0
  32. package/lib/utils/jf2.js +170 -0
  33. package/lib/utils/pagination.js +157 -0
  34. package/lib/utils/validation.js +217 -0
  35. package/lib/webmention/processor.js +214 -0
  36. package/lib/webmention/receiver.js +54 -0
  37. package/lib/webmention/verifier.js +308 -0
  38. package/lib/websub/discovery.js +129 -0
  39. package/lib/websub/handler.js +163 -0
  40. package/lib/websub/subscriber.js +181 -0
  41. package/locales/en.json +80 -0
  42. package/package.json +54 -0
  43. package/views/channel-new.njk +33 -0
  44. package/views/channel.njk +41 -0
  45. package/views/compose.njk +61 -0
  46. package/views/item.njk +85 -0
  47. package/views/partials/actions.njk +15 -0
  48. package/views/partials/author.njk +17 -0
  49. package/views/partials/item-card.njk +65 -0
  50. package/views/partials/timeline.njk +10 -0
  51. package/views/reader.njk +37 -0
  52. package/views/settings.njk +81 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Server-Sent Events broker
3
+ * Manages SSE connections and event distribution
4
+ * @module realtime/broker
5
+ */
6
+
7
+ import { subscribeToChannel } from "../cache/redis.js";
8
+
9
+ /**
10
+ * SSE Client connection
11
+ * @typedef {object} SseClient
12
+ * @property {object} response - Express response object
13
+ * @property {string} userId - User ID
14
+ * @property {Set<string>} channels - Subscribed channel IDs
15
+ */
16
+
17
+ /** @type {Map<object, SseClient>} */
18
+ const clients = new Map();
19
+
20
+ /** @type {Map<string, object>} Map of userId to Redis subscriber */
21
+ const userSubscribers = new Map();
22
+
23
+ const PING_INTERVAL = 10_000; // 10 seconds
24
+
25
+ /**
26
+ * Add a client to the broker
27
+ * @param {object} response - Express response object
28
+ * @param {string} userId - User ID
29
+ * @param {object} application - Indiekit application
30
+ * @returns {object} Client object
31
+ */
32
+ export function addClient(response, userId, application) {
33
+ const client = {
34
+ response,
35
+ userId,
36
+ channels: new Set(),
37
+ pingInterval: setInterval(() => {
38
+ sendEvent(response, "ping", { timestamp: new Date().toISOString() });
39
+ }, PING_INTERVAL),
40
+ };
41
+
42
+ clients.set(response, client);
43
+
44
+ // Set up Redis subscription for this user if not already done
45
+ setupUserSubscription(userId, application);
46
+
47
+ return client;
48
+ }
49
+
50
+ /**
51
+ * Remove a client from the broker
52
+ * @param {object} response - Express response object
53
+ */
54
+ export function removeClient(response) {
55
+ const client = clients.get(response);
56
+ if (client) {
57
+ clearInterval(client.pingInterval);
58
+ clients.delete(response);
59
+
60
+ // Check if any other clients for this user
61
+ const hasOtherClients = [...clients.values()].some(
62
+ (c) => c.userId === client.userId,
63
+ );
64
+ if (!hasOtherClients) {
65
+ // Could clean up Redis subscription here if needed
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Subscribe a client to a channel
72
+ * @param {object} response - Express response object
73
+ * @param {string} channelId - Channel ID
74
+ */
75
+ export function subscribeClient(response, channelId) {
76
+ const client = clients.get(response);
77
+ if (client) {
78
+ client.channels.add(channelId);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Unsubscribe a client from a channel
84
+ * @param {object} response - Express response object
85
+ * @param {string} channelId - Channel ID
86
+ */
87
+ export function unsubscribeClient(response, channelId) {
88
+ const client = clients.get(response);
89
+ if (client) {
90
+ client.channels.delete(channelId);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Send an event to a specific client
96
+ * @param {object} response - Express response object
97
+ * @param {string} event - Event name
98
+ * @param {object} data - Event data
99
+ */
100
+ export function sendEvent(response, event, data) {
101
+ try {
102
+ response.write(`event: ${event}\n`);
103
+ response.write(`data: ${JSON.stringify(data)}\n\n`);
104
+ } catch {
105
+ // Client disconnected
106
+ removeClient(response);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Broadcast an event to all clients subscribed to a channel
112
+ * @param {string} channelId - Channel ID
113
+ * @param {string} event - Event name
114
+ * @param {object} data - Event data
115
+ */
116
+ export function broadcastToChannel(channelId, event, data) {
117
+ for (const client of clients.values()) {
118
+ if (client.channels.has(channelId)) {
119
+ sendEvent(client.response, event, data);
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Broadcast an event to all clients for a user
126
+ * @param {string} userId - User ID
127
+ * @param {string} event - Event name
128
+ * @param {object} data - Event data
129
+ */
130
+ export function broadcastToUser(userId, event, data) {
131
+ for (const client of clients.values()) {
132
+ if (client.userId === userId) {
133
+ sendEvent(client.response, event, data);
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Broadcast an event to all connected clients
140
+ * @param {string} event - Event name
141
+ * @param {object} data - Event data
142
+ */
143
+ export function broadcastToAll(event, data) {
144
+ for (const client of clients.values()) {
145
+ sendEvent(client.response, event, data);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Set up Redis subscription for a user
151
+ * @param {string} userId - User ID
152
+ * @param {object} application - Indiekit application
153
+ */
154
+ async function setupUserSubscription(userId, application) {
155
+ if (userSubscribers.has(userId)) {
156
+ return; // Already subscribed
157
+ }
158
+
159
+ const redis = application.redis;
160
+ if (!redis) {
161
+ return; // No Redis, skip real-time
162
+ }
163
+
164
+ // Create a duplicate connection for pub/sub
165
+ const subscriber = redis.duplicate();
166
+ userSubscribers.set(userId, subscriber);
167
+
168
+ try {
169
+ await subscribeToChannel(subscriber, `microsub:user:${userId}`, (data) => {
170
+ handleRedisEvent(userId, data);
171
+ });
172
+ } catch {
173
+ // Subscription failed, remove from map
174
+ userSubscribers.delete(userId);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Handle event received from Redis
180
+ * @param {string} userId - User ID
181
+ * @param {object} data - Event data
182
+ */
183
+ function handleRedisEvent(userId, data) {
184
+ const { type, channelId, ...eventData } = data;
185
+
186
+ switch (type) {
187
+ case "new-item": {
188
+ broadcastToUser(userId, "new-item", { channelId, ...eventData });
189
+ break;
190
+ }
191
+ case "channel-update": {
192
+ broadcastToUser(userId, "channel-update", { channelId, ...eventData });
193
+ break;
194
+ }
195
+ case "unread-count": {
196
+ broadcastToUser(userId, "unread-count", { channelId, ...eventData });
197
+ break;
198
+ }
199
+ default: {
200
+ // Unknown event type, broadcast as generic event
201
+ broadcastToUser(userId, type, data);
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Get broker statistics
208
+ * @returns {object} Statistics
209
+ */
210
+ export function getStats() {
211
+ const userCounts = new Map();
212
+ for (const client of clients.values()) {
213
+ const count = userCounts.get(client.userId) || 0;
214
+ userCounts.set(client.userId, count + 1);
215
+ }
216
+
217
+ return {
218
+ totalClients: clients.size,
219
+ uniqueUsers: userCounts.size,
220
+ userSubscribers: userSubscribers.size,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Clean up all connections
226
+ */
227
+ export function cleanup() {
228
+ for (const client of clients.values()) {
229
+ clearInterval(client.pingInterval);
230
+ }
231
+ clients.clear();
232
+
233
+ for (const subscriber of userSubscribers.values()) {
234
+ try {
235
+ subscriber.quit();
236
+ } catch {
237
+ // Ignore cleanup errors
238
+ }
239
+ }
240
+ userSubscribers.clear();
241
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Search indexer for MongoDB text search
3
+ * @module search/indexer
4
+ */
5
+
6
+ /**
7
+ * Create text indexes for microsub items
8
+ * @param {object} application - Indiekit application
9
+ * @returns {Promise<void>}
10
+ */
11
+ export async function createSearchIndexes(application) {
12
+ const itemsCollection = application.collections.get("microsub_items");
13
+
14
+ // Create compound text index for full-text search
15
+ await itemsCollection.createIndex(
16
+ {
17
+ name: "text",
18
+ "content.text": "text",
19
+ "content.html": "text",
20
+ summary: "text",
21
+ "author.name": "text",
22
+ },
23
+ {
24
+ name: "text_search",
25
+ weights: {
26
+ name: 10,
27
+ "content.text": 5,
28
+ summary: 3,
29
+ "author.name": 2,
30
+ },
31
+ default_language: "english",
32
+ background: true,
33
+ },
34
+ );
35
+
36
+ // Create index for channel + published for efficient timeline queries
37
+ await itemsCollection.createIndex(
38
+ { channelId: 1, published: -1 },
39
+ { name: "channel_timeline" },
40
+ );
41
+
42
+ // Create index for deduplication
43
+ await itemsCollection.createIndex(
44
+ { channelId: 1, uid: 1 },
45
+ { name: "channel_uid", unique: true },
46
+ );
47
+
48
+ // Create index for feed-based queries
49
+ await itemsCollection.createIndex({ feedId: 1 }, { name: "feed_items" });
50
+ }
51
+
52
+ /**
53
+ * Rebuild search indexes (drops and recreates)
54
+ * @param {object} application - Indiekit application
55
+ * @returns {Promise<void>}
56
+ */
57
+ export async function rebuildSearchIndexes(application) {
58
+ const itemsCollection = application.collections.get("microsub_items");
59
+
60
+ // Drop existing text index
61
+ try {
62
+ await itemsCollection.dropIndex("text_search");
63
+ } catch {
64
+ // Index may not exist
65
+ }
66
+
67
+ // Recreate indexes
68
+ await createSearchIndexes(application);
69
+ }
70
+
71
+ /**
72
+ * Get search index stats
73
+ * @param {object} application - Indiekit application
74
+ * @returns {Promise<object>} Index statistics
75
+ */
76
+ export async function getSearchIndexStats(application) {
77
+ const itemsCollection = application.collections.get("microsub_items");
78
+
79
+ const indexes = await itemsCollection.indexes();
80
+ const stats = await itemsCollection.stats();
81
+
82
+ return {
83
+ indexes: indexes.map((index) => ({
84
+ name: index.name,
85
+ key: index.key,
86
+ })),
87
+ totalDocuments: stats.count,
88
+ size: stats.size,
89
+ };
90
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Search query module for full-text search
3
+ * @module search/query
4
+ */
5
+
6
+ import { ObjectId } from "mongodb";
7
+
8
+ /**
9
+ * Search items using MongoDB text search
10
+ * @param {object} application - Indiekit application
11
+ * @param {ObjectId|string} channelId - Channel ObjectId
12
+ * @param {string} query - Search query string
13
+ * @param {object} options - Search options
14
+ * @param {number} [options.limit] - Max results (default 20)
15
+ * @param {number} [options.skip] - Skip results for pagination
16
+ * @param {boolean} [options.sortByScore] - Sort by relevance (default true)
17
+ * @returns {Promise<Array>} Array of matching items
18
+ */
19
+ export async function searchItemsFullText(
20
+ application,
21
+ channelId,
22
+ query,
23
+ options = {},
24
+ ) {
25
+ const collection = application.collections.get("microsub_items");
26
+ const { limit = 20, skip = 0, sortByScore = true } = options;
27
+
28
+ const channelObjectId =
29
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
30
+
31
+ // Build the search query
32
+ const searchQuery = {
33
+ channelId: channelObjectId,
34
+ $text: { $search: query },
35
+ };
36
+
37
+ // Build aggregation pipeline for scoring
38
+ const pipeline = [
39
+ { $match: searchQuery },
40
+ { $addFields: { score: { $meta: "textScore" } } },
41
+ ];
42
+
43
+ if (sortByScore) {
44
+ pipeline.push(
45
+ { $sort: { score: -1, published: -1 } },
46
+ { $skip: skip },
47
+ { $limit: limit },
48
+ );
49
+ } else {
50
+ pipeline.push(
51
+ { $sort: { published: -1 } },
52
+ { $skip: skip },
53
+ { $limit: limit },
54
+ );
55
+ }
56
+
57
+ const items = await collection.aggregate(pipeline).toArray();
58
+
59
+ return items.map((item) => transformToSearchResult(item));
60
+ }
61
+
62
+ /**
63
+ * Search items using regex fallback (for partial matching)
64
+ * @param {object} application - Indiekit application
65
+ * @param {ObjectId|string} channelId - Channel ObjectId
66
+ * @param {string} query - Search query string
67
+ * @param {object} options - Search options
68
+ * @returns {Promise<Array>} Array of matching items
69
+ */
70
+ export async function searchItemsRegex(
71
+ application,
72
+ channelId,
73
+ query,
74
+ options = {},
75
+ ) {
76
+ const collection = application.collections.get("microsub_items");
77
+ const { limit = 20 } = options;
78
+
79
+ const channelObjectId =
80
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
81
+
82
+ // Escape regex special characters
83
+ const escapedQuery = query.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
84
+ const regex = new RegExp(escapedQuery, "i");
85
+
86
+ const items = await collection
87
+ .find({
88
+ channelId: channelObjectId,
89
+ $or: [
90
+ { name: regex },
91
+ { "content.text": regex },
92
+ { "content.html": regex },
93
+ { summary: regex },
94
+ { "author.name": regex },
95
+ ],
96
+ })
97
+ .toSorted({ published: -1 })
98
+ .limit(limit)
99
+ .toArray();
100
+
101
+ return items.map((item) => transformToSearchResult(item));
102
+ }
103
+
104
+ /**
105
+ * Search with automatic fallback
106
+ * Uses full-text search first, falls back to regex if no results
107
+ * @param {object} application - Indiekit application
108
+ * @param {ObjectId|string} channelId - Channel ObjectId
109
+ * @param {string} query - Search query string
110
+ * @param {object} options - Search options
111
+ * @returns {Promise<Array>} Array of matching items
112
+ */
113
+ export async function searchWithFallback(
114
+ application,
115
+ channelId,
116
+ query,
117
+ options = {},
118
+ ) {
119
+ // Try full-text search first
120
+ try {
121
+ const results = await searchItemsFullText(
122
+ application,
123
+ channelId,
124
+ query,
125
+ options,
126
+ );
127
+ if (results.length > 0) {
128
+ return results;
129
+ }
130
+ } catch {
131
+ // Text index might not exist, fall through to regex
132
+ }
133
+
134
+ // Fall back to regex search
135
+ return searchItemsRegex(application, channelId, query, options);
136
+ }
137
+
138
+ /**
139
+ * Transform database item to search result format
140
+ * @param {object} item - Database item
141
+ * @returns {object} Search result
142
+ */
143
+ function transformToSearchResult(item) {
144
+ const result = {
145
+ type: item.type || "entry",
146
+ uid: item.uid,
147
+ url: item.url,
148
+ published: item.published?.toISOString(),
149
+ _id: item._id.toString(),
150
+ };
151
+
152
+ if (item.name) result.name = item.name;
153
+ if (item.content) result.content = item.content;
154
+ if (item.summary) result.summary = item.summary;
155
+ if (item.author) result.author = item.author;
156
+ if (item.photo?.length > 0) result.photo = item.photo;
157
+ if (item.score) result._score = item.score;
158
+
159
+ return result;
160
+ }
161
+
162
+ /**
163
+ * Get search suggestions (autocomplete)
164
+ * @param {object} application - Indiekit application
165
+ * @param {ObjectId|string} channelId - Channel ObjectId
166
+ * @param {string} prefix - Search prefix
167
+ * @param {number} limit - Max suggestions
168
+ * @returns {Promise<Array>} Array of suggestions
169
+ */
170
+ export async function getSearchSuggestions(
171
+ application,
172
+ channelId,
173
+ prefix,
174
+ limit = 5,
175
+ ) {
176
+ const collection = application.collections.get("microsub_items");
177
+
178
+ const channelObjectId =
179
+ typeof channelId === "string" ? new ObjectId(channelId) : channelId;
180
+
181
+ const escapedPrefix = prefix.replaceAll(
182
+ /[$()*+.?[\\\]^{|}]/g,
183
+ String.raw`\$&`,
184
+ );
185
+ const regex = new RegExp(`^${escapedPrefix}`, "i");
186
+
187
+ // Get unique names/titles that match prefix
188
+ const results = await collection
189
+ .aggregate([
190
+ { $match: { channelId: channelObjectId, name: regex } },
191
+ { $group: { _id: "$name" } },
192
+ { $limit: limit },
193
+ ])
194
+ .toArray();
195
+
196
+ return results.map((r) => r._id).filter(Boolean);
197
+ }