import path from "node:path" import {promises as fs} from "node:fs" 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, normalizeRelayUrl} from "@welshman/util" import {load} from "cheerio" import {Hono} from "hono" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const BUILD_DIR = path.join(__dirname, "build") const INDEX_PATH = path.join(BUILD_DIR, "index.html") const PORT = parseInt(process.env.PORT || "", 10) || 3000 const HOST = process.env.HOST || "0.0.0.0" 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 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 requestUrlFromContext = context => { const requestUrl = new URL(context.req.url) const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim() const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim() if (forwardedProto === "http" || forwardedProto === "https") { requestUrl.protocol = `${forwardedProto}:` } if (forwardedHost) { requestUrl.host = forwardedHost } return requestUrl } const resolveMetadata = async requestUrl => { const pathname = requestUrl.pathname let relayParam = 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]) } } if (!relayParam) { return undefined } try { // Note: fetchRelay from @welshman/app handles the ws->http conversion and caching const relayMetadata = await fetchRelay(normalizeRelayUrl(relayParam)) if (!relayMetadata) { return undefined } const relayDisplay = displayRelayUrl(relayParam) const spaceName = relayMetadata.name const relayDescription = relayMetadata.description const title = spaceName ? `Invite to ${spaceName} on ${DEFAULT_PLATFORM_NAME}` : `Invite to a Space on ${DEFAULT_PLATFORM_NAME}` 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) 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 injectMeta = metadata => { const $ = load(TEMPLATE_HTML) if (metadata.title) { $("title").text(metadata.title) $('meta[property="og:title"]').attr("content", metadata.title) $('meta[name="twitter:title"]').attr("content", metadata.title) } 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) } if (metadata.image) { $('meta[property="og:image"]').attr("content", metadata.image) $('meta[name="twitter:image"]').attr("content", metadata.image) } 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) } return $.html() } const app = new Hono() // Only allow GET and HEAD requests app.use("*", async (context, next) => { 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) => { 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) }, }), ) // 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 metadata = await resolveMetadata(requestUrl) const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML return context.html(html, 200, { "Cache-Control": metadata ? "no-store" : "no-cache", }) }) serve( { fetch: app.fetch, hostname: HOST, port: PORT, }, () => { console.log(`Flotilla server running on http://${HOST}:${PORT}`) }, )