refactor: simplify server.js using hono and cheerio, improve metadata injection
This commit is contained in:
@@ -5,7 +5,7 @@ import {fileURLToPath} from "node:url"
|
|||||||
import {serve} from "@hono/node-server"
|
import {serve} from "@hono/node-server"
|
||||||
import {serveStatic} from "@hono/node-server/serve-static"
|
import {serveStatic} from "@hono/node-server/serve-static"
|
||||||
import {fetchRelay} from "@welshman/app"
|
import {fetchRelay} from "@welshman/app"
|
||||||
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {load} from "cheerio"
|
import {load} from "cheerio"
|
||||||
import {Hono} from "hono"
|
import {Hono} from "hono"
|
||||||
|
|
||||||
@@ -15,592 +15,193 @@ const __dirname = path.dirname(__filename)
|
|||||||
const BUILD_DIR = path.join(__dirname, "build")
|
const BUILD_DIR = path.join(__dirname, "build")
|
||||||
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
|
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
|
||||||
|
|
||||||
const readPositiveInt = (value, fallback) => {
|
const PORT = parseInt(process.env.PORT || "", 10) || 3000
|
||||||
const parsed = Number.parseInt(value || "", 10)
|
|
||||||
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const PORT = readPositiveInt(process.env.PORT, 3000)
|
|
||||||
const HOST = process.env.HOST || "0.0.0.0"
|
const HOST = process.env.HOST || "0.0.0.0"
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = readPositiveInt(process.env.INVITE_PREVIEW_TIMEOUT_MS, 1200)
|
let TEMPLATE_HTML = ""
|
||||||
const CACHE_MAX_ITEMS = readPositiveInt(process.env.INVITE_PREVIEW_CACHE_MAX, 500)
|
try {
|
||||||
const POSITIVE_CACHE_TTL_MS = readPositiveInt(
|
TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8")
|
||||||
process.env.INVITE_PREVIEW_CACHE_TTL_MS,
|
} catch (error) {
|
||||||
15 * 60 * 1000,
|
console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`)
|
||||||
)
|
process.exit(1)
|
||||||
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)}...`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizeText = (value, limit) => {
|
const TEMPLATE_DOCUMENT = load(TEMPLATE_HTML)
|
||||||
if (typeof value !== "string") {
|
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
|
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 requestUrlFromContext = context => {
|
||||||
const requestUrl = new URL(context.req.url)
|
const requestUrl = new URL(context.req.url)
|
||||||
const forwardedProto = firstHeaderValue(context.req.header("x-forwarded-proto"))
|
const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim()
|
||||||
const forwardedHost = firstHeaderValue(context.req.header("x-forwarded-host"))
|
const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim()
|
||||||
|
|
||||||
try {
|
if (forwardedProto === "http" || forwardedProto === "https") {
|
||||||
if (forwardedProto === "http" || forwardedProto === "https") {
|
requestUrl.protocol = `${forwardedProto}:`
|
||||||
requestUrl.protocol = `${forwardedProto}:`
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (forwardedHost) {
|
if (forwardedHost) {
|
||||||
requestUrl.host = forwardedHost
|
requestUrl.host = forwardedHost
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return requestUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestUrl
|
return requestUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
const absoluteUrlFromRequest = (requestUrl, value) => {
|
const resolveMetadata = async requestUrl => {
|
||||||
try {
|
const pathname = requestUrl.pathname
|
||||||
return new URL(value, requestUrl.origin).toString()
|
let relayParam = undefined
|
||||||
} catch {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeImageUrl = (value, baseUrl) => {
|
// Match /join?r=...
|
||||||
if (typeof value !== "string") {
|
if (pathname === "/join" || pathname === "/join/") {
|
||||||
return undefined
|
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 (!relayParam) {
|
||||||
|
|
||||||
if (!trimmed) {
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageUrl.toString()
|
const relayDisplay = displayRelayUrl(relayParam)
|
||||||
} catch {
|
const spaceName = relayMetadata.name
|
||||||
return undefined
|
const relayDescription = relayMetadata.description
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeRelayInput = value => {
|
const title = spaceName
|
||||||
const trimmed = value.trim()
|
? `Invite to ${spaceName} on ${DEFAULT_PLATFORM_NAME}`
|
||||||
|
: `Invite to a Space on ${DEFAULT_PLATFORM_NAME}`
|
||||||
|
|
||||||
if (!trimmed) {
|
const parts = []
|
||||||
return undefined
|
if (spaceName) {
|
||||||
}
|
parts.push(`You are invited to join ${spaceName} on ${DEFAULT_PLATFORM_NAME}.`)
|
||||||
|
|
||||||
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:"
|
|
||||||
} else {
|
} else {
|
||||||
return undefined
|
parts.push(`You are invited to join a space on ${DEFAULT_PLATFORM_NAME}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
relayHttpUrl.hash = ""
|
if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`)
|
||||||
relayHttpUrl.search = ""
|
if (relayDescription) parts.push(relayDescription)
|
||||||
|
else parts.push(DEFAULT_PLATFORM_DESCRIPTION)
|
||||||
|
|
||||||
return relayHttpUrl.toString()
|
const description = parts.join(" ")
|
||||||
} catch {
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTimeout = async (promise, timeoutMs) =>
|
const injectMeta = metadata => {
|
||||||
new Promise(resolve => {
|
const $ = load(TEMPLATE_HTML)
|
||||||
if (timeoutMs <= 0) {
|
|
||||||
resolve(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => resolve(undefined), timeoutMs)
|
if (metadata.title) {
|
||||||
|
$("title").text(metadata.title)
|
||||||
promise
|
$('meta[property="og:title"]').attr("content", metadata.title)
|
||||||
.then(resolve)
|
$('meta[name="twitter:title"]').attr("content", metadata.title)
|
||||||
.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 {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoUrl = relayToInfoUrl(relayUrl) || relayUrl
|
if (metadata.description) {
|
||||||
const name = sanitizeText(relayMetadata.name || relayMetadata.title, 80)
|
$('meta[name="description"]').attr("content", metadata.description)
|
||||||
const description = sanitizeText(relayMetadata.description, 180)
|
$('meta[property="og:description"]').attr("content", metadata.description)
|
||||||
const icon = normalizeImageUrl(
|
$('meta[name="twitter:description"]').attr("content", metadata.description)
|
||||||
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 {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
if (metadata.image) {
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
$('meta[property="og:image"]').attr("content", metadata.image)
|
||||||
|
$('meta[name="twitter:image"]').attr("content", metadata.image)
|
||||||
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 (cached.expiresAt <= Date.now()) {
|
if (metadata.url) {
|
||||||
cacheByRelay.delete(relayUrl)
|
$('meta[property="og:url"]').attr("content", metadata.url)
|
||||||
return undefined
|
$('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("<head></head>")
|
|
||||||
|
|
||||||
return $("head").first()
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsertTitle = ($, title) => {
|
|
||||||
const head = ensureHead($)
|
|
||||||
let titleTag = head.children("title").first()
|
|
||||||
|
|
||||||
if (titleTag.length === 0) {
|
|
||||||
titleTag = $("<title></title>")
|
|
||||||
head.prepend(titleTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
titleTag.text(title)
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsertMetaTag = ($, key, content, attribute) => {
|
|
||||||
const head = ensureHead($)
|
|
||||||
let tag = findMeta($, key)
|
|
||||||
|
|
||||||
if (tag.length === 0) {
|
|
||||||
tag = $("<meta>")
|
|
||||||
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 = $("<link>")
|
|
||||||
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()
|
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()
|
const app = new Hono()
|
||||||
|
|
||||||
|
// Only allow GET and HEAD requests
|
||||||
app.use("*", async (context, next) => {
|
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"})
|
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
|
||||||
}
|
}
|
||||||
|
|
||||||
await next()
|
await next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Serve static assets with appropriate caching
|
||||||
app.use(
|
app.use(
|
||||||
"*",
|
"*",
|
||||||
serveStatic({
|
serveStatic({
|
||||||
root: BUILD_DIR,
|
root: BUILD_DIR,
|
||||||
onFound: (filePath, context) => {
|
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)
|
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)) {
|
if (path.extname(requestUrl.pathname)) {
|
||||||
return context.text("Not found", 404)
|
return context.text("Not found", 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dynamicInvite = isJoinInvitePath(requestUrl.pathname) && requestUrl.searchParams.has("r")
|
const metadata = await resolveMetadata(requestUrl)
|
||||||
const html = await renderIndex(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(
|
serve(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<title>{NAME}</title>
|
||||||
|
<link rel="canonical" href="{URL}" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="{NAME}" />
|
<meta property="og:title" content="{NAME}" />
|
||||||
<meta property="og:description" content="{DESCRIPTION}" />
|
<meta property="og:description" content="{DESCRIPTION}" />
|
||||||
|
<meta property="og:image" content="" />
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="twitter:site" content="{URL}" />
|
<meta name="twitter:site" content="{URL}" />
|
||||||
<meta name="twitter:title" content="{NAME}" />
|
<meta name="twitter:title" content="{NAME}" />
|
||||||
|
|||||||
Reference in New Issue
Block a user