diff --git a/Dockerfile b/Dockerfile index 780ce934..c7f45313 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,12 +21,16 @@ ENV VITE_BUILD_HASH=${VITE_BUILD_HASH} ENV NODE_OPTIONS=--max_old_space_size=16384 RUN pnpm run build +RUN pnpm prune --prod -FROM node:20-alpine +FROM node:20-bookworm-slim WORKDIR /app -# Copy only the built output - no source, no .env, no dev deps +# Copy production runtime only +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/build ./build +COPY --from=builder /app/server.js ./server.js -CMD ["npx", "serve", "-s", "build"] +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 6092fd32..2c861e7d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ To run your own Flotilla, it's as simple as: ```sh pnpm install pnpm run build -npx serve -s build +node server.js ``` Or, if you prefer to use a container: diff --git a/package.json b/package.json index 3a433de1..bc11daf5 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "vite dev", + "start": "node server.js", "build": "./build.sh", "release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner", "tauri:dev": "tauri dev", @@ -60,6 +61,7 @@ "@capawesome/capacitor-badge": "^8.0.0", "@getalby/lightning-tools": "^6.1.0", "@getalby/sdk": "^5.1.2", + "@hono/node-server": "^1.19.14", "@noble/curves": "^1.9.7", "@pomade/core": "^0.2.3", "@poppanator/sveltekit-svg": "^4.2.1", @@ -80,12 +82,14 @@ "@welshman/signer": "^0.8.13", "@welshman/store": "^0.8.13", "@welshman/util": "^0.8.13", + "cheerio": "^1.2.0", "compressorjs-next": "^1.1.2", "daisyui": "^5.5.19", "date-picker-svelte": "^2.17.0", "dotenv": "^16.6.1", "emoji-picker-element": "^1.28.1", "fuse.js": "^7.1.0", + "hono": "^4.12.14", "husky": "^9.1.7", "idb": "^8.0.3", "livekit-client": "^2.17.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e02625b7..a3370db8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@getalby/sdk': specifier: ^5.1.2 version: 5.1.2(typescript@5.9.3) + '@hono/node-server': + specifier: ^1.19.14 + version: 1.19.14(hono@4.12.14) '@noble/curves': specifier: ^1.9.7 version: 1.9.7 @@ -122,6 +125,9 @@ importers: '@welshman/util': specifier: ^0.8.13 version: 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)) + cheerio: + specifier: ^1.2.0 + version: 1.2.0 compressorjs-next: specifier: ^1.1.2 version: 1.1.2 @@ -140,6 +146,9 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.1.0 + hono: + specifier: ^4.12.14 + version: 4.12.14 husky: specifier: ^9.1.7 version: 9.1.7 @@ -1096,6 +1105,12 @@ packages: resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==} engines: {node: '>=14'} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2465,6 +2480,13 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + chevrotain@7.1.1: resolution: {integrity: sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==} @@ -2830,6 +2852,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} @@ -2841,6 +2866,14 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3234,6 +3267,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + hono@4.12.14: + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} + engines: {node: '>=16.9.0'} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -3241,6 +3278,9 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -3249,6 +3289,10 @@ packages: ico-endec@0.1.6: resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -4015,6 +4059,15 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -4456,6 +4509,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.1.4: resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==} @@ -4891,6 +4947,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -5009,6 +5069,15 @@ packages: resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==} engines: {node: '>=6.0.0', npm: '>=3.10.0'} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -6228,6 +6297,10 @@ snapshots: transitivePeerDependencies: - typescript + '@hono/node-server@1.19.14(hono@4.12.14)': + dependencies: + hono: 4.12.14 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -7642,6 +7715,29 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.25.0 + whatwg-mimetype: 4.0.0 + chevrotain@7.1.1: dependencies: regexp-to-ast: 0.5.0 @@ -8032,6 +8128,11 @@ snapshots: emoji-regex@8.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -8041,6 +8142,10 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + + entities@7.0.1: {} + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -8550,16 +8655,29 @@ snapshots: he@1.2.0: {} + hono@4.12.14: {} + hosted-git-info@2.8.9: {} hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + husky@9.1.7: {} ico-endec@0.1.6: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + idb@7.1.1: {} idb@8.0.3: {} @@ -9266,6 +9384,19 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -9704,6 +9835,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + sax@1.1.4: {} sax@1.4.4: {} @@ -10229,6 +10362,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.25.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -10309,6 +10444,12 @@ snapshots: dependencies: sdp: 3.2.1 + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/server.js b/server.js new file mode 100644 index 00000000..427367e8 --- /dev/null +++ b/server.js @@ -0,0 +1,785 @@ +// @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("