import path from "node:path" import {promises as fs} from "node:fs" import {fileURLToPath} from "node:url" import "dotenv/config" import {serve} from "@hono/node-server" import {serveStatic} from "@hono/node-server/serve-static" import {loadRelay} 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 PLATFORM_NAME = process.env.VITE_PLATFORM_NAME const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION // 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 fetchRelayMeta = async relayUrl => { if (!relayUrl) return undefined try { return await loadRelay(normalizeRelayUrl(relayUrl)) } catch (err) { console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err) return undefined } } const buildDefaultImage = requestUrl => { return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString() } const getMetadataForInvite = async (url, match) => { const relayParam = url.searchParams.get("r") if (!relayParam) return undefined const relayMetadata = await fetchRelayMeta(relayParam) if (!relayMetadata) return undefined const relayDisplay = displayRelayUrl(relayParam) const spaceName = relayMetadata.name const relayDescription = relayMetadata.description const title = spaceName ? `Invite to ${spaceName} on ${PLATFORM_NAME}` : `Invite to a Space on ${PLATFORM_NAME}` const parts = [] if (spaceName) { parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`) } else { parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`) } if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`) if (relayDescription) parts.push(relayDescription) else parts.push(PLATFORM_DESCRIPTION) const description = parts.join(" ") const image = relayMetadata.icon || relayMetadata.picture || relayMetadata.image || buildDefaultImage(url) return { title, description, image, url: url.toString(), site: url.origin, } } const getMetadataForSpace = async (url, match) => { const relayParam = decodeRelay(match[1]) if (!relayParam) return undefined const relayMetadata = await fetchRelayMeta(relayParam) if (!relayMetadata) return undefined const spaceName = relayMetadata.name || displayRelayUrl(relayParam) return { title: `${spaceName} on ${PLATFORM_NAME}`, description: relayMetadata.description || PLATFORM_DESCRIPTION, image: relayMetadata.icon || relayMetadata.picture || relayMetadata.image || buildDefaultImage(url), url: url.toString(), site: url.origin, } } const getMetadataForSpaceSection = async (url, match) => { const spaceMeta = await getMetadataForSpace(url, match) if (!spaceMeta) return undefined const section = match[2] const sectionName = section.charAt(0).toUpperCase() + section.slice(1) spaceMeta.title = `${sectionName} on ${spaceMeta.title}` return spaceMeta } const getMetadataForSpaceItem = async (url, match) => { const spaceMeta = await getMetadataForSpace(url, match) if (!spaceMeta) return undefined const section = match[2] let itemType = "Item" if (section === "calendar") itemType = "Event" if (section === "threads") itemType = "Thread" if (section === "polls") itemType = "Poll" if (section === "goals") itemType = "Goal" if (section === "classifieds") itemType = "Listing" spaceMeta.title = `${itemType} on ${spaceMeta.title}` return spaceMeta } const getMetadataForRoom = async (url, match) => { const spaceMeta = await getMetadataForSpace(url, match) if (!spaceMeta) return undefined // Room metadata requires fetching from Nostr, which can be added later. spaceMeta.title = `Room on ${spaceMeta.title}` return spaceMeta } const routes = [ [/^\/join\/?$/, getMetadataForInvite], [/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection], [/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem], [/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom], [/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace], ] const getMetadataForRoute = async url => { for (const [regex, getMetadata] of routes) { const match = url.pathname.match(regex) if (match) { try { return await getMetadata(url, match) } catch (err) { console.error(`Error generating metadata for route ${url.pathname}:`, err) return undefined } } } 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) // Immutable assets are content-hashed by Vite, so the filename is itself a // stable content identifier. Exposing it as an ETag lets clients that // revalidate explicitly (e.g. emoji-picker-element checks its data source // on every load) skip re-downloading large files when nothing changed. if (isImmutable) { context.header("ETag", `"${path.basename(filePath)}"`) } }, }), ) // 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 getMetadataForRoute(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}`) }, )