From e380e4e3d671de0e5a609202dacfaafcd8cfbfd3 Mon Sep 17 00:00:00 2001 From: 1amKhush Date: Wed, 29 Apr 2026 17:02:10 +0530 Subject: [PATCH] refactor: simplify server.js using hono and cheerio, improve metadata injection --- server.js | 627 ++++++++++----------------------------------------- src/app.html | 3 + 2 files changed, 117 insertions(+), 513 deletions(-) diff --git a/server.js b/server.js index 46ddf002..6d337d65 100644 --- a/server.js +++ b/server.js @@ -5,7 +5,7 @@ import {fileURLToPath} from "node:url" import {serve} from "@hono/node-server" import {serveStatic} from "@hono/node-server/serve-static" import {fetchRelay} from "@welshman/app" -import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util" +import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util" import {load} from "cheerio" import {Hono} from "hono" @@ -15,592 +15,193 @@ const __dirname = path.dirname(__filename) const BUILD_DIR = path.join(__dirname, "build") const INDEX_PATH = path.join(BUILD_DIR, "index.html") -const readPositiveInt = (value, fallback) => { - const parsed = Number.parseInt(value || "", 10) - - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback -} - -const PORT = readPositiveInt(process.env.PORT, 3000) +const PORT = parseInt(process.env.PORT || "", 10) || 3000 const HOST = process.env.HOST || "0.0.0.0" -const REQUEST_TIMEOUT_MS = readPositiveInt(process.env.INVITE_PREVIEW_TIMEOUT_MS, 1200) -const CACHE_MAX_ITEMS = readPositiveInt(process.env.INVITE_PREVIEW_CACHE_MAX, 500) -const POSITIVE_CACHE_TTL_MS = readPositiveInt( - process.env.INVITE_PREVIEW_CACHE_TTL_MS, - 15 * 60 * 1000, -) -const NEGATIVE_CACHE_TTL_MS = readPositiveInt( - process.env.INVITE_PREVIEW_NEGATIVE_CACHE_TTL_MS, - 2 * 60 * 1000, -) - -const ALLOWED_METHODS = new Set(["GET", "HEAD"]) -const DEFAULT_IMAGE_PATH = "/maskable-icon-512x512.png" -const HTML_HEADERS = {"Content-Type": "text/html; charset=utf-8"} - -const truncate = (value, limit) => { - if (value.length <= limit) { - return value - } - - if (limit <= 3) { - return value.slice(0, limit) - } - - return `${value.slice(0, limit - 3)}...` +let TEMPLATE_HTML = "" +try { + TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8") +} catch (error) { + console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`) + process.exit(1) } -const sanitizeText = (value, limit) => { - if (typeof value !== "string") { +const TEMPLATE_DOCUMENT = load(TEMPLATE_HTML) +const DEFAULT_PLATFORM_NAME = + process.env.VITE_PLATFORM_NAME || + TEMPLATE_DOCUMENT('meta[property="og:title"]').attr("content") || + "Flotilla" +const DEFAULT_PLATFORM_DESCRIPTION = + process.env.VITE_PLATFORM_DESCRIPTION || + TEMPLATE_DOCUMENT('meta[name="description"]').attr("content") || + "Flotilla is nostr - for communities." + +// Match client-side decode logic +const decodeRelay = url => { + try { + return normalizeRelayUrl(decodeURIComponent(url)) + } catch { return undefined } - - const compact = value.replace(/\s+/g, " ").trim() - - if (!compact) { - return undefined - } - - return truncate(compact, limit) -} - -const isRecord = value => Boolean(value) && typeof value === "object" && !Array.isArray(value) - -const hasMetadata = metadata => Object.keys(metadata).length > 0 - -const firstHeaderValue = value => { - if (typeof value !== "string") { - return undefined - } - - return sanitizeText(value.split(",")[0], 256) } const requestUrlFromContext = context => { const requestUrl = new URL(context.req.url) - const forwardedProto = firstHeaderValue(context.req.header("x-forwarded-proto")) - const forwardedHost = firstHeaderValue(context.req.header("x-forwarded-host")) + const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim() + const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim() - try { - if (forwardedProto === "http" || forwardedProto === "https") { - requestUrl.protocol = `${forwardedProto}:` - } + if (forwardedProto === "http" || forwardedProto === "https") { + requestUrl.protocol = `${forwardedProto}:` + } - if (forwardedHost) { - requestUrl.host = forwardedHost - } - } catch { - return requestUrl + if (forwardedHost) { + requestUrl.host = forwardedHost } return requestUrl } -const absoluteUrlFromRequest = (requestUrl, value) => { - try { - return new URL(value, requestUrl.origin).toString() - } catch { - return value - } -} +const resolveMetadata = async requestUrl => { + const pathname = requestUrl.pathname + let relayParam = undefined -const normalizeImageUrl = (value, baseUrl) => { - if (typeof value !== "string") { - return undefined + // Match /join?r=... + if (pathname === "/join" || pathname === "/join/") { + relayParam = requestUrl.searchParams.get("r") + } + // Match /spaces/:relay/... + else if (pathname.startsWith("/spaces/")) { + const parts = pathname.split("/").filter(Boolean) + if (parts.length >= 2) { + relayParam = decodeRelay(parts[1]) + } } - const trimmed = value.trim() - - if (!trimmed) { + if (!relayParam) { return undefined } try { - const imageUrl = new URL(trimmed, baseUrl) + // Note: fetchRelay from @welshman/app handles the ws->http conversion and caching + const relayMetadata = await fetchRelay(normalizeRelayUrl(relayParam)) - if (!["http:", "https:"].includes(imageUrl.protocol)) { + if (!relayMetadata) { return undefined } - return imageUrl.toString() - } catch { - return undefined - } -} + const relayDisplay = displayRelayUrl(relayParam) + const spaceName = relayMetadata.name + const relayDescription = relayMetadata.description -const normalizeRelayInput = value => { - const trimmed = value.trim() + const title = spaceName + ? `Invite to ${spaceName} on ${DEFAULT_PLATFORM_NAME}` + : `Invite to a Space on ${DEFAULT_PLATFORM_NAME}` - if (!trimmed) { - return undefined - } - - let relayInput = trimmed - - try { - const parsed = new URL(trimmed) - - if (parsed.protocol === "http:") { - parsed.protocol = "ws:" - relayInput = parsed.toString() - } - - if (parsed.protocol === "https:") { - parsed.protocol = "wss:" - relayInput = parsed.toString() - } - } catch { - relayInput = trimmed - } - - if (!isRelayUrl(relayInput)) { - return undefined - } - - try { - const relayUrl = new URL(normalizeRelayUrl(relayInput)) - - relayUrl.hash = "" - relayUrl.search = "" - - return relayUrl.toString() - } catch { - return undefined - } -} - -const relayToInfoUrl = relayUrl => { - try { - const relayHttpUrl = new URL(relayUrl) - - if (relayHttpUrl.protocol === "ws:") { - relayHttpUrl.protocol = "http:" - } else if (relayHttpUrl.protocol === "wss:") { - relayHttpUrl.protocol = "https:" + const parts = [] + if (spaceName) { + parts.push(`You are invited to join ${spaceName} on ${DEFAULT_PLATFORM_NAME}.`) } else { - return undefined + parts.push(`You are invited to join a space on ${DEFAULT_PLATFORM_NAME}.`) } - relayHttpUrl.hash = "" - relayHttpUrl.search = "" + if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`) + if (relayDescription) parts.push(relayDescription) + else parts.push(DEFAULT_PLATFORM_DESCRIPTION) - return relayHttpUrl.toString() - } catch { + const description = parts.join(" ") + const image = + relayMetadata.icon || + relayMetadata.picture || + relayMetadata.image || + new URL("/maskable-icon-512x512.png", requestUrl.origin).toString() + + return { + title, + description, + image, + url: requestUrl.toString(), + site: requestUrl.origin, + } + } catch (err) { return undefined } } -const withTimeout = async (promise, timeoutMs) => - new Promise(resolve => { - if (timeoutMs <= 0) { - resolve(undefined) - return - } +const injectMeta = metadata => { + const $ = load(TEMPLATE_HTML) - const timeout = setTimeout(() => resolve(undefined), timeoutMs) - - promise - .then(resolve) - .catch(() => resolve(undefined)) - .finally(() => clearTimeout(timeout)) - }) - -const findMeta = ($, key) => { - const normalizedKey = key.toLowerCase() - - return $("meta") - .filter((_, element) => { - const meta = $(element) - const name = meta.attr("name")?.toLowerCase() - const property = meta.attr("property")?.toLowerCase() - - return name === normalizedKey || property === normalizedKey - }) - .first() -} - -const readMetaContent = ($, key) => findMeta($, key).attr("content") - -const readIconHref = $ => { - let href - - $("link[rel]").each((_, element) => { - if (href) { - return - } - - const link = $(element) - const rel = link.attr("rel")?.toLowerCase() || "" - - if (rel.includes("icon")) { - href = link.attr("href") - } - }) - - return href -} - -const findLinkRel = ($, rel) => - $("link[rel]") - .filter((_, element) => ($(element).attr("rel")?.toLowerCase() || "").includes(rel)) - .first() - -const extractHtmlMetadata = (html, baseUrl) => { - const $ = load(html) - const name = sanitizeText($("title").first().text(), 80) - const description = sanitizeText(readMetaContent($, "description") || "", 180) - const icon = normalizeImageUrl( - readMetaContent($, "og:image") || readMetaContent($, "twitter:image") || readIconHref($) || "", - baseUrl, - ) - - return { - ...(name ? {name} : {}), - ...(description ? {description} : {}), - ...(icon ? {icon} : {}), - } -} - -const normalizeRelayMetadata = (relayMetadata, relayUrl) => { - if (!isRecord(relayMetadata)) { - return {} + if (metadata.title) { + $("title").text(metadata.title) + $('meta[property="og:title"]').attr("content", metadata.title) + $('meta[name="twitter:title"]').attr("content", metadata.title) } - const infoUrl = relayToInfoUrl(relayUrl) || relayUrl - const name = sanitizeText(relayMetadata.name || relayMetadata.title, 80) - const description = sanitizeText(relayMetadata.description, 180) - const icon = normalizeImageUrl( - relayMetadata.icon || relayMetadata.picture || relayMetadata.image, - infoUrl, - ) - - return { - ...(name ? {name} : {}), - ...(description ? {description} : {}), - ...(icon ? {icon} : {}), - } -} - -const fetchHtmlRelayMetadata = async (relayUrl, timeoutMs) => { - const infoUrl = relayToInfoUrl(relayUrl) - - if (!infoUrl || timeoutMs <= 0) { - return {} + if (metadata.description) { + $('meta[name="description"]').attr("content", metadata.description) + $('meta[property="og:description"]').attr("content", metadata.description) + $('meta[name="twitter:description"]').attr("content", metadata.description) } - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), timeoutMs) - - try { - const response = await fetch(infoUrl, { - headers: { - Accept: "text/html, application/xhtml+xml, */*;q=0.1", - }, - redirect: "follow", - signal: controller.signal, - }) - - if (!response.ok) { - return {} - } - - return extractHtmlMetadata(await response.text(), infoUrl) - } catch { - return {} - } finally { - clearTimeout(timeout) - } -} - -const cacheByRelay = new Map() -const inFlightByRelay = new Map() - -const getCachedRelayData = relayUrl => { - const cached = cacheByRelay.get(relayUrl) - - if (cached === undefined) { - return undefined + if (metadata.image) { + $('meta[property="og:image"]').attr("content", metadata.image) + $('meta[name="twitter:image"]').attr("content", metadata.image) } - if (cached.expiresAt <= Date.now()) { - cacheByRelay.delete(relayUrl) - return undefined + if (metadata.url) { + $('meta[property="og:url"]').attr("content", metadata.url) + $('meta[name="twitter:site"]').attr("content", metadata.site) + $('meta[name="twitter:url"]').attr("content", metadata.url) + $('link[rel="canonical"]').attr("href", metadata.url) } - cacheByRelay.delete(relayUrl) - cacheByRelay.set(relayUrl, cached) - - return cached.value -} - -const setCachedRelayData = (relayUrl, value, ttlMs) => { - if (cacheByRelay.size >= CACHE_MAX_ITEMS) { - const oldestKey = cacheByRelay.keys().next().value - - if (oldestKey !== undefined) { - cacheByRelay.delete(oldestKey) - } - } - - cacheByRelay.set(relayUrl, { - expiresAt: Date.now() + ttlMs, - value, - }) -} - -const fetchRelayMetadata = async relayUrl => { - const cached = getCachedRelayData(relayUrl) - - if (cached !== undefined) { - return cached - } - - const inFlight = inFlightByRelay.get(relayUrl) - - if (inFlight !== undefined) { - return inFlight - } - - const loader = (async () => { - const deadline = Date.now() + REQUEST_TIMEOUT_MS - const remainingTimeout = () => Math.max(0, deadline - Date.now()) - const relay = await withTimeout(fetchRelay(relayUrl), remainingTimeout()) - let metadata = normalizeRelayMetadata(relay, relayUrl) - - if (!hasMetadata(metadata)) { - metadata = await fetchHtmlRelayMetadata(relayUrl, remainingTimeout()) - } - - setCachedRelayData( - relayUrl, - metadata, - hasMetadata(metadata) ? POSITIVE_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS, - ) - - return metadata - })() - - inFlightByRelay.set(relayUrl, loader) - - try { - return await loader - } finally { - inFlightByRelay.delete(relayUrl) - } -} - -const isJoinInvitePath = pathname => pathname === "/join" || pathname === "/join/" - -const parseInvite = requestUrl => { - const relayParam = requestUrl.searchParams.get("r") || "" - const relayUrl = normalizeRelayInput(relayParam) - - if (!relayUrl) { - return undefined - } - - const claim = sanitizeText(requestUrl.searchParams.get("c") || "", 256) || "" - - return {relayUrl, claim} -} - -const loadIndexTemplate = async () => { - try { - return await fs.readFile(INDEX_PATH, "utf8") - } catch (error) { - console.error(`Unable to read ${INDEX_PATH}. Run \"pnpm run build\" first.`) - throw error - } -} - -const INDEX_TEMPLATE = await loadIndexTemplate() -const INDEX_DOCUMENT = load(INDEX_TEMPLATE) -const DEFAULT_PLATFORM_NAME = - sanitizeText(process.env.VITE_PLATFORM_NAME, 80) || - sanitizeText(readMetaContent(INDEX_DOCUMENT, "og:title"), 80) || - sanitizeText(readMetaContent(INDEX_DOCUMENT, "twitter:title"), 80) || - "Flotilla" -const DEFAULT_PLATFORM_DESCRIPTION = - sanitizeText(process.env.VITE_PLATFORM_DESCRIPTION, 180) || - sanitizeText(readMetaContent(INDEX_DOCUMENT, "description"), 180) || - "Flotilla is nostr - for communities." - -const buildInviteDescription = ({spaceName, relayDisplay, relayDescription}) => { - const parts = [] - - if (spaceName) { - parts.push(`You are invited to join ${spaceName} on ${DEFAULT_PLATFORM_NAME}.`) - } else { - parts.push(`You are invited to join a space on ${DEFAULT_PLATFORM_NAME}.`) - } - - if (relayDisplay) { - parts.push(`Relay: ${relayDisplay}.`) - } - - if (relayDescription) { - parts.push(relayDescription) - } else { - parts.push(DEFAULT_PLATFORM_DESCRIPTION) - } - - return sanitizeText(parts.join(" "), 220) || DEFAULT_PLATFORM_DESCRIPTION -} - -const buildInviteMeta = (requestUrl, invite, relayMetadata) => { - const relayDisplay = displayRelayUrl(invite.relayUrl) - const spaceName = sanitizeText(relayMetadata.name, 80) - const relayDescription = sanitizeText(relayMetadata.description, 180) - const title = spaceName - ? `Invite to ${spaceName} on ${DEFAULT_PLATFORM_NAME}` - : `Invite to a Space on ${DEFAULT_PLATFORM_NAME}` - const description = buildInviteDescription({spaceName, relayDisplay, relayDescription}) - const image = relayMetadata.icon || absoluteUrlFromRequest(requestUrl, DEFAULT_IMAGE_PATH) - const url = requestUrl.toString() - const site = requestUrl.origin - - return {title, description, image, url, site} -} - -const ensureHead = $ => { - const existingHead = $("head").first() - - if (existingHead.length > 0) { - return existingHead - } - - $("html").prepend("") - - return $("head").first() -} - -const upsertTitle = ($, title) => { - const head = ensureHead($) - let titleTag = head.children("title").first() - - if (titleTag.length === 0) { - titleTag = $("") - head.prepend(titleTag) - } - - titleTag.text(title) -} - -const upsertMetaTag = ($, key, content, attribute) => { - const head = ensureHead($) - let tag = findMeta($, key) - - if (tag.length === 0) { - tag = $("") - head.append(tag) - } - - tag.removeAttr(attribute === "name" ? "property" : "name") - tag.attr(attribute, key) - tag.attr("content", content) -} - -const upsertCanonical = ($, href) => { - const head = ensureHead($) - let tag = findLinkRel($, "canonical") - - if (tag.length === 0) { - tag = $("") - head.append(tag) - } - - tag.attr("rel", "canonical") - tag.attr("href", href) -} - -const injectInviteMeta = (html, metadata) => { - const $ = load(html) - - upsertTitle($, metadata.title) - upsertCanonical($, metadata.url) - upsertMetaTag($, "description", metadata.description, "name") - upsertMetaTag($, "og:type", "website", "property") - upsertMetaTag($, "og:url", metadata.url, "property") - upsertMetaTag($, "og:title", metadata.title, "property") - upsertMetaTag($, "og:description", metadata.description, "property") - upsertMetaTag($, "og:image", metadata.image, "property") - upsertMetaTag($, "twitter:card", "summary_large_image", "name") - upsertMetaTag($, "twitter:site", metadata.site, "name") - upsertMetaTag($, "twitter:url", metadata.url, "name") - upsertMetaTag($, "twitter:title", metadata.title, "name") - upsertMetaTag($, "twitter:description", metadata.description, "name") - upsertMetaTag($, "twitter:image", metadata.image, "name") - return $.html() } -const renderIndex = async requestUrl => { - if (!isJoinInvitePath(requestUrl.pathname)) { - return INDEX_TEMPLATE - } - - const invite = parseInvite(requestUrl) - - if (!invite) { - return INDEX_TEMPLATE - } - - const relayMetadata = await fetchRelayMetadata(invite.relayUrl) - const inviteMeta = buildInviteMeta(requestUrl, invite, relayMetadata) - - return injectInviteMeta(INDEX_TEMPLATE, inviteMeta) -} - -const isImmutableAsset = filePath => filePath.split(path.sep).join("/").includes("/_app/immutable/") - -const getStaticCacheControl = filePath => - path.basename(filePath) === "index.html" - ? "no-cache" - : isImmutableAsset(filePath) - ? "public, max-age=31536000, immutable" - : "public, max-age=3600" - -const respondHtml = (html, isHeadRequest, cacheControl) => - new Response(isHeadRequest ? undefined : html, { - headers: { - ...HTML_HEADERS, - "Cache-Control": cacheControl, - }, - status: 200, - }) - const app = new Hono() +// Only allow GET and HEAD requests app.use("*", async (context, next) => { - if (!ALLOWED_METHODS.has(context.req.method)) { + const method = context.req.method + if (method !== "GET" && method !== "HEAD") { return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"}) } - await next() }) +// Serve static assets with appropriate caching app.use( "*", serveStatic({ root: BUILD_DIR, onFound: (filePath, context) => { - context.header("Cache-Control", getStaticCacheControl(filePath)) + const isImmutable = filePath.split(path.sep).join("/").includes("/_app/immutable/") + const cacheControl = + path.basename(filePath) === "index.html" + ? "no-cache" + : isImmutable + ? "public, max-age=31536000, immutable" + : "public, max-age=3600" + + context.header("Cache-Control", cacheControl) }, }), ) -app.on(["GET", "HEAD"], "*", async context => { +// SPA fallback for routes that don't match static files +app.get("*", async context => { const requestUrl = requestUrlFromContext(context) + // If the path has an extension, it's likely a missing static asset, not an SPA route if (path.extname(requestUrl.pathname)) { return context.text("Not found", 404) } - const dynamicInvite = isJoinInvitePath(requestUrl.pathname) && requestUrl.searchParams.has("r") - const html = await renderIndex(requestUrl) + const metadata = await resolveMetadata(requestUrl) + const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML - return respondHtml(html, context.req.method === "HEAD", dynamicInvite ? "no-store" : "no-cache") + return context.html(html, 200, { + "Cache-Control": metadata ? "no-store" : "no-cache", + }) }) serve( diff --git a/src/app.html b/src/app.html index 05fab318..3bab4e05 100644 --- a/src/app.html +++ b/src/app.html @@ -2,6 +2,8 @@ + {NAME} + @@ -11,6 +13,7 @@ +