/**
* WebSub hub discovery
* @module websub/discovery
*/
/**
* Discover WebSub hub from HTTP response headers and content
* @param {object} response - Fetch response object
* @param {string} content - Response body content
* @returns {object|undefined} WebSub info { hub, self }
*/
export function discoverWebsub(response, content) {
// Try to find hub and self URLs from Link headers first
const linkHeader = response.headers.get("link");
const fromHeaders = linkHeader ? parseLinkHeader(linkHeader) : {};
// Fall back to content parsing
const fromContent = parseContentForLinks(content);
const hub = fromHeaders.hub || fromContent.hub;
const self = fromHeaders.self || fromContent.self;
if (hub) {
return { hub, self };
}
return;
}
/**
* Parse Link header for hub and self URLs
* @param {string} linkHeader - Link header value
* @returns {object} { hub, self }
*/
function parseLinkHeader(linkHeader) {
const result = {};
const links = linkHeader.split(",");
for (const link of links) {
const parts = link.trim().split(";");
if (parts.length < 2) continue;
const urlMatch = parts[0].match(/<([^>]+)>/);
if (!urlMatch) continue;
const url = urlMatch[1];
const relationship = parts
.slice(1)
.find((p) => p.trim().startsWith("rel="))
?.match(/rel=["']?([^"'\s;]+)["']?/)?.[1];
if (relationship === "hub") {
result.hub = url;
} else if (relationship === "self") {
result.self = url;
}
}
return result;
}
/**
* Parse content for hub and self URLs (Atom, RSS, HTML)
* @param {string} content - Response body
* @returns {object} { hub, self }
*/
function parseContentForLinks(content) {
const result = {};
// Try HTML elements
const htmlHubMatch = content.match(
/]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
);
if (htmlHubMatch) {
result.hub = htmlHubMatch[1];
}
const htmlSelfMatch = content.match(
/]+rel=["']?self["']?[^>]+href=["']([^"']+)["']/i,
);
if (htmlSelfMatch) {
result.self = htmlSelfMatch[1];
}
// Also try the reverse order (href before rel)
if (!result.hub) {
const htmlHubMatch2 = content.match(
/]+href=["']([^"']+)["'][^>]+rel=["']?hub["']?/i,
);
if (htmlHubMatch2) {
result.hub = htmlHubMatch2[1];
}
}
if (!result.self) {
const htmlSelfMatch2 = content.match(
/]+href=["']([^"']+)["'][^>]+rel=["']?self["']?/i,
);
if (htmlSelfMatch2) {
result.self = htmlSelfMatch2[1];
}
}
// Try Atom elements
if (!result.hub) {
const atomHubMatch = content.match(
/]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
);
if (atomHubMatch) {
result.hub = atomHubMatch[1];
}
}
return result;
}
/**
* Check if a hub URL is valid
* @param {string} hubUrl - Hub URL to validate
* @returns {boolean} Whether the URL is valid
*/
export function isValidHubUrl(hubUrl) {
try {
const url = new URL(hubUrl);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
}