// @ts-nocheck import {readFile} from "node:fs/promises" import {dirname, extname, join} from "node:path" import {fileURLToPath} from "node:url" import {load as loadHtml} from "cheerio" import {serve} from "@hono/node-server" import {serveStatic} from "@hono/node-server/serve-static" import {Hono} from "hono" import {request} from "@welshman/net" import { Address, CLASSIFIED, EVENT_TIME, POLL, ROOM_META, THREAD, ZAP_GOAL, displayPubkey, getTagValue, normalizeRelayUrl, readRoomMeta, } from "@welshman/util" const __dirname = dirname(fileURLToPath(import.meta.url)) const buildDir = join(__dirname, "build") const indexPath = join(buildDir, "index.html") const RELAY_CACHE_TTL_MS = 5 * 60 * 1000 const NOSTR_CACHE_TTL_MS = 60 * 1000 const RELAY_TIMEOUT_MS = 1500 const NOSTR_TIMEOUT_MS = 1800 const staticTitles = new Map([ ["/", "Redirecting"], ["/home", "Home"], ["/spaces", "Spaces"], ["/spaces/create", "Create a Space"], ["/chat", "Messages"], ["/join", "Join Space"], ["/people", "Find People"], ["/settings/about", "About"], ["/settings/profile", "Profile Settings"], ["/settings/content", "Content Settings"], ["/settings/privacy", "Privacy Settings"], ["/settings/relays", "Relay Settings"], ["/settings/alerts", "Alert Settings"], ["/settings/wallet", "Wallet Settings"], ]) const spaceSectionTitles = new Map([ ["chat", "Space Chat"], ["recent", "Recent Activity"], ["threads", "Threads"], ["classifieds", "Classifieds"], ["calendar", "Calendar"], ["goals", "Goals"], ["polls", "Polls"], ]) const eventRouteKinds = new Map([ ["threads", THREAD], ["goals", ZAP_GOAL], ["calendar", EVENT_TIME], ["classifieds", CLASSIFIED], ["polls", POLL], ]) const reservedSingleSegments = new Set([ "home", "spaces", "space", "chat", "join", "people", "settings", ]) const relayInfoCache = new Map() const roomInfoCache = new Map() const eventCache = new Map() const indexHtml = await readFile(indexPath, "utf8").catch(error => { console.error("Unable to start server: build/index.html is missing. Run `pnpm run build` first.") throw error }) const defaults = getHtmlDefaults(indexHtml) const app = new Hono() const staticFiles = serveStatic({ root: "./build", rewriteRequestPath: path => path.replace(/^\/+/, ""), }) app.use("*", staticFiles) app.get("*", async c => { const requestUrl = new URL(c.req.url) if (extname(requestUrl.pathname)) { return c.text("Not Found", 404) } const origin = getRequestOrigin(c.req.raw, requestUrl) const meta = await buildRouteMeta(requestUrl, origin) const html = renderHtml(indexHtml, meta) c.header("Cache-Control", "no-cache") return c.html(html) }) app.notFound(c => c.text("Not Found", 404)) app.onError((error, c) => { console.error(error) return c.text("Internal Server Error", 500) }) const port = Number.parseInt(process.env.PORT || "3000", 10) const host = process.env.HOST || "0.0.0.0" serve({fetch: app.fetch, hostname: host, port}) console.log(`Flotilla server listening on http://${host}:${port}`) async function buildRouteMeta(requestUrl, origin) { const absoluteDefaultImage = toAbsoluteHttpUrl(defaults.image, origin) if (!absoluteDefaultImage) { throw new Error(`Default twitter:image must resolve to an absolute URL. Found: ${defaults.image}`) } const meta = { card: "summary", description: defaults.description, image: absoluteDefaultImage, site: defaults.site, title: defaults.title, type: "website", url: requestUrl.href, } const route = parseRoute(requestUrl.pathname) if (route.kind === "join") { return await buildJoinMeta(meta, requestUrl, origin) } if (route.kind === "static") { meta.title = route.title return meta } if (route.kind === "chat") { meta.title = getChatTitle(route.chat) return meta } if (route.kind === "bech32") { meta.title = "Opening Link" return meta } if (!route.relay) { return meta } const relayUrl = normalizeRelayParam(route.relay) const relayInfo = relayUrl ? await loadRelayInfo(relayUrl) : undefined const relayName = relayInfo?.name || (relayUrl ? getRelayDisplay(relayUrl) : "Space") const relayHttpUrl = relayUrl ? toRelayHttpUrl(relayUrl) : undefined if (relayInfo?.icon) { meta.image = relayInfo.icon } if (relayInfo?.description) { meta.description = relayInfo.description } if (route.kind === "space") { meta.title = relayName return meta } if (route.kind === "space-section") { meta.title = composeSpaceTitle(relayName, route.sectionTitle) return meta } if (route.kind === "room") { const roomInfo = relayUrl ? await loadRoomInfo(relayUrl, route.h) : undefined const roomName = roomInfo?.name || route.h meta.title = composeSpaceTitle(relayName, roomName) meta.description = roomInfo?.about || meta.description const roomImage = roomInfo?.picture ? toAbsoluteHttpUrl(roomInfo.picture, relayHttpUrl || origin) : undefined if (roomImage) { meta.image = roomImage } return meta } if (route.kind === "event") { const event = relayUrl ? await loadEventForRoute(relayUrl, route.section, route.identifier) : undefined const eventTitle = getEventTitle(route.section, event) meta.title = composeSpaceTitle(relayName, eventTitle) meta.description = getEventDescription(route.section, event, meta.description) const eventImage = getTagValue("image", event?.tags || []) const absoluteEventImage = eventImage ? toAbsoluteHttpUrl(eventImage, relayHttpUrl || origin) : undefined if (absoluteEventImage) { meta.image = absoluteEventImage meta.card = "summary_large_image" } return meta } return meta } function parseRoute(pathname) { const normalizedPath = normalizePathname(pathname) if (normalizedPath === "/join") { return {kind: "join"} } if (staticTitles.has(normalizedPath)) { return {kind: "static", title: staticTitles.get(normalizedPath)} } const segments = getPathSegments(normalizedPath) if (segments.length === 2 && segments[0] === "chat") { return {chat: segments[1], kind: "chat"} } if (segments.length === 1 && !reservedSingleSegments.has(segments[0])) { return {bech32: segments[0], kind: "bech32"} } if ((segments[0] === "spaces" || segments[0] === "space") && segments.length >= 2) { const relay = segments[1] if (segments.length === 2) { return {kind: "space", relay} } const section = segments[2] if (segments.length === 3) { if (spaceSectionTitles.has(section)) { return { kind: "space-section", relay, section, sectionTitle: spaceSectionTitles.get(section), } } return {h: section, kind: "room", relay} } if (segments.length === 4 && eventRouteKinds.has(section)) { return { identifier: segments[3], kind: "event", relay, section, } } } return {kind: "unknown"} } async function buildJoinMeta(meta, requestUrl, origin) { const relayUrl = parseInviteRelay(requestUrl) if (!relayUrl) { meta.title = staticTitles.get("/join") || "Join Space" return meta } const relayInfo = await loadRelayInfo(relayUrl) const relayDisplay = relayInfo?.name || getRelayDisplay(relayUrl) meta.title = `Invitation to join ${relayDisplay}` meta.description = relayInfo?.description || `Join this Flotilla space on ${relayDisplay}.` meta.image = relayInfo?.icon || meta.image meta.url = requestUrl.href meta.site = defaults.site return meta } function getChatTitle(chat) { if (!chat) { return "Chat" } const peers = chat .split(",") .map(part => part.trim()) .filter(Boolean) if (peers.length === 1) { return `Chat with ${displayPubkey(peers[0])}` } if (peers.length > 1) { return `Group chat (${peers.length})` } return "Chat" } function getEventTitle(section, event) { if (section === "threads") { return getTagValue("title", event?.tags || []) || "Thread" } if (section === "calendar") { return getTagValue("title", event?.tags || []) || "Event" } if (section === "classifieds") { return getTagValue("title", event?.tags || []) || "Listing" } if (section === "goals") { return event?.content?.trim() || getTagValue("summary", event?.tags || []) || "Goal" } if (section === "polls") { return getTagValue("title", event?.tags || []) || "Poll" } return "Event" } function getEventDescription(section, event, fallback) { const summary = getTagValue("summary", event?.tags || []) || getTagValue("description", event?.tags || []) if (summary) { return clip(summary, 220) } if (event?.content?.trim()) { return clip(event.content.trim(), 220) } if (section === "threads") { return "Read this thread in Flotilla." } if (section === "goals") { return "Track this goal in Flotilla." } if (section === "calendar") { return "View this calendar event in Flotilla." } if (section === "classifieds") { return "Browse this listing in Flotilla." } if (section === "polls") { return "Take this poll in Flotilla." } return fallback } function composeSpaceTitle(spaceName, leafTitle) { const cleanedSpace = spaceName?.trim() const cleanedLeaf = leafTitle?.trim() if (cleanedSpace && cleanedLeaf) { return `${cleanedSpace} / ${cleanedLeaf}` } return cleanedLeaf || cleanedSpace || defaults.title } async function loadRelayInfo(relayUrl) { const cached = getCachedValue(relayInfoCache, relayUrl) if (cached) { return cached } const relayHttpUrl = toRelayHttpUrl(relayUrl) if (!relayHttpUrl) { setCachedValue(relayInfoCache, relayUrl, undefined, RELAY_CACHE_TTL_MS) return undefined } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS) let value try { const response = await fetch(relayHttpUrl, { headers: {Accept: "application/nostr+json"}, signal: controller.signal, }) if (response.ok) { const json = await response.json() const name = typeof json.name === "string" ? json.name.trim() : "" const description = typeof json.description === "string" ? json.description.trim() : "" const icon = typeof json.icon === "string" ? toAbsoluteHttpUrl(json.icon, relayHttpUrl) : undefined value = { description: description || undefined, icon, name: name || undefined, } } } catch { value = undefined } finally { clearTimeout(timeout) } setCachedValue(relayInfoCache, relayUrl, value, RELAY_CACHE_TTL_MS) return value } async function loadRoomInfo(relayUrl, h) { const cacheKey = `${relayUrl}|${h}` const cached = getCachedValue(roomInfoCache, cacheKey) if (cached !== undefined) { return cached } const events = await requestEvents(relayUrl, [{"#d": [h], kinds: [ROOM_META], limit: 20}]) const roomMetas = [] for (const event of events) { try { const roomMeta = readRoomMeta(event) if (roomMeta.h === h) { roomMetas.push(roomMeta) } } catch { // Ignore malformed room metadata. } } const latest = roomMetas.sort((a, b) => b.event.created_at - a.event.created_at)[0] const roomInfo = latest ? { about: latest.about, name: latest.name, picture: latest.picture, } : undefined setCachedValue(roomInfoCache, cacheKey, roomInfo, NOSTR_CACHE_TTL_MS) return roomInfo } async function loadEventForRoute(relayUrl, section, identifier) { const kind = eventRouteKinds.get(section) if (!kind || !identifier) { return undefined } const cacheKey = `${relayUrl}|${section}|${identifier}` const cached = getCachedValue(eventCache, cacheKey) if (cached !== undefined) { return cached } const filters = getEventFilters(kind, identifier) const events = filters.length > 0 ? await requestEvents(relayUrl, filters) : [] const event = events[0] setCachedValue(eventCache, cacheKey, event, NOSTR_CACHE_TTL_MS) return event } function getEventFilters(kind, identifier) { if (kind === EVENT_TIME || kind === CLASSIFIED) { try { const address = Address.from(identifier) return [ { "#d": [address.identifier], authors: [address.pubkey], kinds: [address.kind], limit: 1, }, ] } catch { return [{ids: [identifier], kinds: [kind], limit: 1}] } } return [{ids: [identifier], kinds: [kind], limit: 1}] } async function requestEvents(relayUrl, filters) { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), NOSTR_TIMEOUT_MS) try { return await request({ autoClose: true, filters, relays: [relayUrl], signal: controller.signal, }) } catch { return [] } finally { clearTimeout(timeout) } } function renderHtml(html, meta) { const $ = loadHtml(html) upsertTitle($, meta.title) upsertMetaTag($, "name", "description", meta.description) upsertMetaTag($, "name", "og:url", meta.url) upsertMetaTag($, "name", "og:type", meta.type) upsertMetaTag($, "name", "og:title", meta.title) upsertMetaTag($, "name", "og:description", meta.description) upsertMetaTag($, "name", "twitter:card", meta.card) upsertMetaTag($, "name", "twitter:site", meta.site) upsertMetaTag($, "name", "twitter:title", meta.title) upsertMetaTag($, "name", "twitter:description", meta.description) upsertMetaTag($, "name", "twitter:image", meta.image) upsertMetaTag($, "property", "og:url", meta.url) upsertMetaTag($, "property", "og:type", meta.type) upsertMetaTag($, "property", "og:title", meta.title) upsertMetaTag($, "property", "og:description", meta.description) upsertMetaTag($, "property", "og:image", meta.image) return $.html() } function upsertTitle($, value) { let titleTag = $("head > title").first() if (titleTag.length === 0) { $("head").prepend("