@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,214 @@
1
+ /**
2
+ * Webmention processor
3
+ * @module webmention/processor
4
+ */
5
+
6
+ import { getRedisClient, publishEvent } from "../cache/redis.js";
7
+ import { ensureNotificationsChannel } from "../storage/channels.js";
8
+
9
+ import { verifyWebmention } from "./verifier.js";
10
+
11
+ /**
12
+ * Get notifications collection
13
+ * @param {object} application - Indiekit application
14
+ * @returns {object} MongoDB collection
15
+ */
16
+ function getCollection(application) {
17
+ return application.collections.get("microsub_notifications");
18
+ }
19
+
20
+ /**
21
+ * Process a webmention
22
+ * @param {object} application - Indiekit application
23
+ * @param {string} source - Source URL
24
+ * @param {string} target - Target URL
25
+ * @param {string} [userId] - User ID (for user-specific notifications)
26
+ * @returns {Promise<object>} Processing result
27
+ */
28
+ export async function processWebmention(application, source, target, userId) {
29
+ // Verify the webmention
30
+ const verification = await verifyWebmention(source, target);
31
+
32
+ if (!verification.verified) {
33
+ console.log(
34
+ `[Microsub] Webmention verification failed: ${verification.error}`,
35
+ );
36
+ return {
37
+ success: false,
38
+ error: verification.error,
39
+ };
40
+ }
41
+
42
+ // Ensure notifications channel exists
43
+ const channel = await ensureNotificationsChannel(application, userId);
44
+
45
+ // Check for existing notification (update if exists)
46
+ const collection = getCollection(application);
47
+ const existing = await collection.findOne({
48
+ source,
49
+ target,
50
+ ...(userId && { userId }),
51
+ });
52
+
53
+ const notification = {
54
+ source,
55
+ target,
56
+ userId,
57
+ channelId: channel._id,
58
+ type: verification.type,
59
+ author: verification.author,
60
+ content: verification.content,
61
+ url: verification.url,
62
+ published: verification.published
63
+ ? new Date(verification.published)
64
+ : new Date(),
65
+ verified: true,
66
+ readBy: [],
67
+ updatedAt: new Date(),
68
+ };
69
+
70
+ if (existing) {
71
+ // Update existing notification
72
+ await collection.updateOne({ _id: existing._id }, { $set: notification });
73
+ notification._id = existing._id;
74
+ } else {
75
+ // Insert new notification
76
+ notification.createdAt = new Date();
77
+ await collection.insertOne(notification);
78
+ }
79
+
80
+ // Publish real-time event
81
+ const redis = getRedisClient(application);
82
+ if (redis && userId) {
83
+ await publishEvent(redis, `microsub:user:${userId}`, {
84
+ type: "new-notification",
85
+ channelId: channel._id.toString(),
86
+ notification: transformNotification(notification),
87
+ });
88
+ }
89
+
90
+ console.log(
91
+ `[Microsub] Webmention processed: ${verification.type} from ${source}`,
92
+ );
93
+
94
+ return {
95
+ success: true,
96
+ type: verification.type,
97
+ id: notification._id?.toString(),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Delete a webmention (when source no longer links to target)
103
+ * @param {object} application - Indiekit application
104
+ * @param {string} source - Source URL
105
+ * @param {string} target - Target URL
106
+ * @returns {Promise<boolean>} Whether deletion was successful
107
+ */
108
+ export async function deleteWebmention(application, source, target) {
109
+ const collection = getCollection(application);
110
+ const result = await collection.deleteOne({ source, target });
111
+ return result.deletedCount > 0;
112
+ }
113
+
114
+ /**
115
+ * Get notifications for a user
116
+ * @param {object} application - Indiekit application
117
+ * @param {string} userId - User ID
118
+ * @param {object} options - Query options
119
+ * @returns {Promise<Array>} Array of notifications
120
+ */
121
+ export async function getNotifications(application, userId, options = {}) {
122
+ const collection = getCollection(application);
123
+ const { limit = 20, unreadOnly = false } = options;
124
+
125
+ const query = { userId };
126
+ if (unreadOnly) {
127
+ query.readBy = { $ne: userId };
128
+ }
129
+
130
+ /* eslint-disable unicorn/no-array-callback-reference -- query is MongoDB query object */
131
+ const notifications = await collection
132
+ .find(query)
133
+ .toSorted({ published: -1 })
134
+ .limit(limit)
135
+ .toArray();
136
+ /* eslint-enable unicorn/no-array-callback-reference */
137
+
138
+ return notifications.map((n) => transformNotification(n, userId));
139
+ }
140
+
141
+ /**
142
+ * Mark notifications as read
143
+ * @param {object} application - Indiekit application
144
+ * @param {string} userId - User ID
145
+ * @param {Array} ids - Notification IDs to mark as read
146
+ * @returns {Promise<number>} Number of notifications updated
147
+ */
148
+ export async function markNotificationsRead(application, userId, ids) {
149
+ const collection = getCollection(application);
150
+ const { ObjectId } = await import("mongodb");
151
+
152
+ const objectIds = ids.map((id) => {
153
+ try {
154
+ return new ObjectId(id);
155
+ } catch {
156
+ return id;
157
+ }
158
+ });
159
+
160
+ const result = await collection.updateMany(
161
+ { _id: { $in: objectIds } },
162
+ { $addToSet: { readBy: userId } },
163
+ );
164
+
165
+ return result.modifiedCount;
166
+ }
167
+
168
+ /**
169
+ * Get unread notification count
170
+ * @param {object} application - Indiekit application
171
+ * @param {string} userId - User ID
172
+ * @returns {Promise<number>} Unread count
173
+ */
174
+ export async function getUnreadNotificationCount(application, userId) {
175
+ const collection = getCollection(application);
176
+ return collection.countDocuments({
177
+ userId,
178
+ readBy: { $ne: userId },
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Transform notification to API format
184
+ * @param {object} notification - Database notification
185
+ * @param {string} [userId] - User ID for read state
186
+ * @returns {object} Transformed notification
187
+ */
188
+ function transformNotification(notification, userId) {
189
+ return {
190
+ type: "entry",
191
+ uid: notification._id?.toString(),
192
+ url: notification.url || notification.source,
193
+ published: notification.published?.toISOString(),
194
+ author: notification.author,
195
+ content: notification.content,
196
+ _source: notification.source,
197
+ _target: notification.target,
198
+ _type: notification.type, // like, reply, repost, bookmark, mention
199
+ _is_read: userId ? notification.readBy?.includes(userId) : false,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Create indexes for notifications
205
+ * @param {object} application - Indiekit application
206
+ * @returns {Promise<void>}
207
+ */
208
+ export async function createNotificationIndexes(application) {
209
+ const collection = getCollection(application);
210
+
211
+ await collection.createIndex({ userId: 1, published: -1 });
212
+ await collection.createIndex({ source: 1, target: 1 });
213
+ await collection.createIndex({ userId: 1, readBy: 1 });
214
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Webmention receiver
3
+ * @module webmention/receiver
4
+ */
5
+
6
+ import { processWebmention } from "./processor.js";
7
+
8
+ /**
9
+ * Receive a webmention
10
+ * POST /microsub/webmention
11
+ * @param {object} request - Express request
12
+ * @param {object} response - Express response
13
+ */
14
+ export async function receive(request, response) {
15
+ const { source, target } = request.body;
16
+
17
+ if (!source || !target) {
18
+ return response.status(400).json({
19
+ error: "invalid_request",
20
+ error_description: "Missing source or target parameter",
21
+ });
22
+ }
23
+
24
+ // Validate URLs
25
+ try {
26
+ new URL(source);
27
+ new URL(target);
28
+ } catch {
29
+ return response.status(400).json({
30
+ error: "invalid_request",
31
+ error_description: "Invalid source or target URL",
32
+ });
33
+ }
34
+
35
+ const { application } = request.app.locals;
36
+ const userId = request.session?.userId;
37
+
38
+ // Return 202 Accepted immediately (processing asynchronously)
39
+ response.status(202).json({
40
+ status: "accepted",
41
+ message: "Webmention queued for processing",
42
+ });
43
+
44
+ // Process webmention in background
45
+ setImmediate(async () => {
46
+ try {
47
+ await processWebmention(application, source, target, userId);
48
+ } catch (error) {
49
+ console.error(`[Microsub] Error processing webmention: ${error.message}`);
50
+ }
51
+ });
52
+ }
53
+
54
+ export const webmentionReceiver = { receive };
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Webmention verification
3
+ * @module webmention/verifier
4
+ */
5
+
6
+ import { mf2 } from "microformats-parser";
7
+
8
+ /**
9
+ * Verify a webmention
10
+ * @param {string} source - Source URL
11
+ * @param {string} target - Target URL
12
+ * @returns {Promise<object>} Verification result
13
+ */
14
+ export async function verifyWebmention(source, target) {
15
+ try {
16
+ // Fetch the source URL
17
+ const response = await fetch(source, {
18
+ headers: {
19
+ Accept: "text/html, application/xhtml+xml",
20
+ "User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
21
+ },
22
+ redirect: "follow",
23
+ });
24
+
25
+ if (!response.ok) {
26
+ return {
27
+ verified: false,
28
+ error: `Source returned ${response.status}`,
29
+ };
30
+ }
31
+
32
+ const content = await response.text();
33
+ const finalUrl = response.url;
34
+
35
+ // Check if source links to target
36
+ if (!containsLink(content, target)) {
37
+ return {
38
+ verified: false,
39
+ error: "Source does not link to target",
40
+ };
41
+ }
42
+
43
+ // Parse microformats
44
+ const parsed = mf2(content, { baseUrl: finalUrl });
45
+ const entry = findEntry(parsed, target);
46
+
47
+ if (!entry) {
48
+ // Still valid, just no h-entry context
49
+ return {
50
+ verified: true,
51
+ type: "mention",
52
+ author: undefined,
53
+ content: undefined,
54
+ };
55
+ }
56
+
57
+ // Determine webmention type
58
+ const mentionType = detectMentionType(entry, target);
59
+
60
+ // Extract author
61
+ const author = extractAuthor(entry, parsed);
62
+
63
+ // Extract content
64
+ const webmentionContent = extractContent(entry);
65
+
66
+ return {
67
+ verified: true,
68
+ type: mentionType,
69
+ author,
70
+ content: webmentionContent,
71
+ url: getFirst(entry.properties.url) || source,
72
+ published: getFirst(entry.properties.published),
73
+ };
74
+ } catch (error) {
75
+ return {
76
+ verified: false,
77
+ error: `Verification failed: ${error.message}`,
78
+ };
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Check if content contains a link to target
84
+ * @param {string} content - HTML content
85
+ * @param {string} target - Target URL to find
86
+ * @returns {boolean} Whether the link exists
87
+ */
88
+ function containsLink(content, target) {
89
+ // Normalize target URL for matching
90
+ const normalizedTarget = target.replace(/\/$/, "");
91
+
92
+ // Check for href attribute containing target
93
+ const hrefPattern = new RegExp(
94
+ `href=["']${escapeRegex(normalizedTarget)}/?["']`,
95
+ "i",
96
+ );
97
+ if (hrefPattern.test(content)) {
98
+ return true;
99
+ }
100
+
101
+ // Also check without quotes (some edge cases)
102
+ return content.includes(target) || content.includes(normalizedTarget);
103
+ }
104
+
105
+ /**
106
+ * Find the h-entry that references the target
107
+ * @param {object} parsed - Parsed microformats
108
+ * @param {string} target - Target URL
109
+ * @returns {object|undefined} The h-entry or undefined
110
+ */
111
+ function findEntry(parsed, target) {
112
+ const normalizedTarget = target.replace(/\/$/, "");
113
+
114
+ for (const item of parsed.items) {
115
+ // Check if this entry references the target
116
+ if (
117
+ item.type?.includes("h-entry") &&
118
+ entryReferencesTarget(item, normalizedTarget)
119
+ ) {
120
+ return item;
121
+ }
122
+
123
+ // Check children
124
+ if (item.children) {
125
+ for (const child of item.children) {
126
+ if (
127
+ child.type?.includes("h-entry") &&
128
+ entryReferencesTarget(child, normalizedTarget)
129
+ ) {
130
+ return child;
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ // Return first h-entry as fallback
137
+ for (const item of parsed.items) {
138
+ if (item.type?.includes("h-entry")) {
139
+ return item;
140
+ }
141
+ }
142
+
143
+ return;
144
+ }
145
+
146
+ /**
147
+ * Check if an entry references the target URL
148
+ * @param {object} entry - h-entry object
149
+ * @param {string} target - Normalized target URL
150
+ * @returns {boolean} Whether the entry references the target
151
+ */
152
+ function entryReferencesTarget(entry, target) {
153
+ const properties = entry.properties || {};
154
+
155
+ // Check interaction properties
156
+ const interactionProperties = [
157
+ "in-reply-to",
158
+ "like-of",
159
+ "repost-of",
160
+ "bookmark-of",
161
+ ];
162
+
163
+ for (const property of interactionProperties) {
164
+ const values = properties[property] || [];
165
+ for (const value of values) {
166
+ const url =
167
+ typeof value === "string" ? value : value?.properties?.url?.[0];
168
+ if (url && normalizeUrl(url) === target) {
169
+ return true;
170
+ }
171
+ }
172
+ }
173
+
174
+ return false;
175
+ }
176
+
177
+ /**
178
+ * Detect the type of webmention
179
+ * @param {object} entry - h-entry object
180
+ * @param {string} target - Target URL
181
+ * @returns {string} Mention type
182
+ */
183
+ function detectMentionType(entry, target) {
184
+ const properties = entry.properties || {};
185
+ const normalizedTarget = target.replace(/\/$/, "");
186
+
187
+ // Check for specific interaction types
188
+ if (matchesTarget(properties["like-of"], normalizedTarget)) {
189
+ return "like";
190
+ }
191
+ if (matchesTarget(properties["repost-of"], normalizedTarget)) {
192
+ return "repost";
193
+ }
194
+ if (matchesTarget(properties["bookmark-of"], normalizedTarget)) {
195
+ return "bookmark";
196
+ }
197
+ if (matchesTarget(properties["in-reply-to"], normalizedTarget)) {
198
+ return "reply";
199
+ }
200
+
201
+ return "mention";
202
+ }
203
+
204
+ /**
205
+ * Check if any value in array matches target
206
+ * @param {Array} values - Array of values
207
+ * @param {string} target - Target URL to match
208
+ * @returns {boolean} Whether any value matches
209
+ */
210
+ function matchesTarget(values, target) {
211
+ if (!values || values.length === 0) return false;
212
+
213
+ for (const value of values) {
214
+ const url = typeof value === "string" ? value : value?.properties?.url?.[0];
215
+ if (url && normalizeUrl(url) === target) {
216
+ return true;
217
+ }
218
+ }
219
+
220
+ return false;
221
+ }
222
+
223
+ /**
224
+ * Extract author from entry or page
225
+ * @param {object} entry - h-entry object
226
+ * @param {object} parsed - Full parsed microformats
227
+ * @returns {object|undefined} Author object
228
+ */
229
+ function extractAuthor(entry, parsed) {
230
+ const author = getFirst(entry.properties?.author);
231
+
232
+ if (typeof author === "string") {
233
+ return { name: author };
234
+ }
235
+
236
+ if (author?.type?.includes("h-card")) {
237
+ return {
238
+ type: "card",
239
+ name: getFirst(author.properties?.name),
240
+ url: getFirst(author.properties?.url),
241
+ photo: getFirst(author.properties?.photo),
242
+ };
243
+ }
244
+
245
+ // Try to find author from page's h-card
246
+ const hcard = parsed.items.find((item) => item.type?.includes("h-card"));
247
+ if (hcard) {
248
+ return {
249
+ type: "card",
250
+ name: getFirst(hcard.properties?.name),
251
+ url: getFirst(hcard.properties?.url),
252
+ photo: getFirst(hcard.properties?.photo),
253
+ };
254
+ }
255
+
256
+ return;
257
+ }
258
+
259
+ /**
260
+ * Extract content from entry
261
+ * @param {object} entry - h-entry object
262
+ * @returns {object|undefined} Content object
263
+ */
264
+ function extractContent(entry) {
265
+ const content = getFirst(entry.properties?.content);
266
+
267
+ if (!content) {
268
+ const summary = getFirst(entry.properties?.summary);
269
+ const name = getFirst(entry.properties?.name);
270
+ return summary || name ? { text: summary || name } : undefined;
271
+ }
272
+
273
+ if (typeof content === "string") {
274
+ return { text: content };
275
+ }
276
+
277
+ return {
278
+ text: content.value,
279
+ html: content.html,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Get first item from array
285
+ * @param {Array|*} value - Value or array
286
+ * @returns {*} First value
287
+ */
288
+ function getFirst(value) {
289
+ return Array.isArray(value) ? value[0] : value;
290
+ }
291
+
292
+ /**
293
+ * Normalize URL for comparison
294
+ * @param {string} url - URL to normalize
295
+ * @returns {string} Normalized URL
296
+ */
297
+ function normalizeUrl(url) {
298
+ return url.replace(/\/$/, "");
299
+ }
300
+
301
+ /**
302
+ * Escape special regex characters
303
+ * @param {string} string - String to escape
304
+ * @returns {string} Escaped string
305
+ */
306
+ function escapeRegex(string) {
307
+ return string.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
308
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * WebSub hub discovery
3
+ * @module websub/discovery
4
+ */
5
+
6
+ /**
7
+ * Discover WebSub hub from HTTP response headers and content
8
+ * @param {object} response - Fetch response object
9
+ * @param {string} content - Response body content
10
+ * @returns {object|undefined} WebSub info { hub, self }
11
+ */
12
+ export function discoverWebsub(response, content) {
13
+ // Try to find hub and self URLs from Link headers first
14
+ const linkHeader = response.headers.get("link");
15
+ const fromHeaders = linkHeader ? parseLinkHeader(linkHeader) : {};
16
+
17
+ // Fall back to content parsing
18
+ const fromContent = parseContentForLinks(content);
19
+
20
+ const hub = fromHeaders.hub || fromContent.hub;
21
+ const self = fromHeaders.self || fromContent.self;
22
+
23
+ if (hub) {
24
+ return { hub, self };
25
+ }
26
+
27
+ return;
28
+ }
29
+
30
+ /**
31
+ * Parse Link header for hub and self URLs
32
+ * @param {string} linkHeader - Link header value
33
+ * @returns {object} { hub, self }
34
+ */
35
+ function parseLinkHeader(linkHeader) {
36
+ const result = {};
37
+ const links = linkHeader.split(",");
38
+
39
+ for (const link of links) {
40
+ const parts = link.trim().split(";");
41
+ if (parts.length < 2) continue;
42
+
43
+ const urlMatch = parts[0].match(/<([^>]+)>/);
44
+ if (!urlMatch) continue;
45
+
46
+ const url = urlMatch[1];
47
+ const relationship = parts
48
+ .slice(1)
49
+ .find((p) => p.trim().startsWith("rel="))
50
+ ?.match(/rel=["']?([^"'\s;]+)["']?/)?.[1];
51
+
52
+ if (relationship === "hub") {
53
+ result.hub = url;
54
+ } else if (relationship === "self") {
55
+ result.self = url;
56
+ }
57
+ }
58
+
59
+ return result;
60
+ }
61
+
62
+ /**
63
+ * Parse content for hub and self URLs (Atom, RSS, HTML)
64
+ * @param {string} content - Response body
65
+ * @returns {object} { hub, self }
66
+ */
67
+ function parseContentForLinks(content) {
68
+ const result = {};
69
+
70
+ // Try HTML <link> elements
71
+ const htmlHubMatch = content.match(
72
+ /<link[^>]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
73
+ );
74
+ if (htmlHubMatch) {
75
+ result.hub = htmlHubMatch[1];
76
+ }
77
+
78
+ const htmlSelfMatch = content.match(
79
+ /<link[^>]+rel=["']?self["']?[^>]+href=["']([^"']+)["']/i,
80
+ );
81
+ if (htmlSelfMatch) {
82
+ result.self = htmlSelfMatch[1];
83
+ }
84
+
85
+ // Also try the reverse order (href before rel)
86
+ if (!result.hub) {
87
+ const htmlHubMatch2 = content.match(
88
+ /<link[^>]+href=["']([^"']+)["'][^>]+rel=["']?hub["']?/i,
89
+ );
90
+ if (htmlHubMatch2) {
91
+ result.hub = htmlHubMatch2[1];
92
+ }
93
+ }
94
+
95
+ if (!result.self) {
96
+ const htmlSelfMatch2 = content.match(
97
+ /<link[^>]+href=["']([^"']+)["'][^>]+rel=["']?self["']?/i,
98
+ );
99
+ if (htmlSelfMatch2) {
100
+ result.self = htmlSelfMatch2[1];
101
+ }
102
+ }
103
+
104
+ // Try Atom <link> elements
105
+ if (!result.hub) {
106
+ const atomHubMatch = content.match(
107
+ /<atom:link[^>]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
108
+ );
109
+ if (atomHubMatch) {
110
+ result.hub = atomHubMatch[1];
111
+ }
112
+ }
113
+
114
+ return result;
115
+ }
116
+
117
+ /**
118
+ * Check if a hub URL is valid
119
+ * @param {string} hubUrl - Hub URL to validate
120
+ * @returns {boolean} Whether the URL is valid
121
+ */
122
+ export function isValidHubUrl(hubUrl) {
123
+ try {
124
+ const url = new URL(hubUrl);
125
+ return url.protocol === "https:" || url.protocol === "http:";
126
+ } catch {
127
+ return false;
128
+ }
129
+ }