fix(metadata): add case-insensitive HTML title fallback parsing for invite links #248

Merged
hodlbod merged 4 commits from Khushvendra/flotilla:issue/131-invite-link-preview into dev 2026-05-04 21:02:57 +00:00
Contributor

Description

This PR implements dynamic, server-side invite link previews for Flotilla Spaces. By introducing a lightweight Node.js server (server.js) to serve the built application, we can now dynamically intercept /join routes and inject rich OpenGraph (OG) and Twitter card metadata.

Previously, invite links to spaces resulted in generic, static previews. Now, when a user shares a space invite link (e.g., .../join?r=relay.scuttle.works&c=...), the server will probe the relay's metadata on the fly and populate the preview card with the relay's specific name, description, and icon.

Key Changes

  • Dynamic Invite Previews: Created server.js to serve the frontend while dynamically generating and injecting SEO/OG metadata for /join invite URLs.
  • NIP-11 Metadata Probing: Added a fast, cached fetching mechanism to retrieve a relay's metadata in JSON format (application/nostr+json).
  • HTML Metadata Fallback: Implemented robust fallback extraction logic for relays that return standard HTML landing pages instead of JSON (such as relay.scuttle.works). The server safely parses the HTML text to extract <title>, <meta description>, and various icon links (og:image, twitter:image, or <link rel="icon">).
  • Case-Insensitive Regex: Hardened the HTML extraction to correctly match HTML tags in a case-insensitive manner, ensuring high reliability across various custom relay setups.
  • Deployment Config: Updated package.json and the Dockerfile to use node server.js as the main start command instead of a static server.
  • Visual Assets Updates: Updated several Android/iOS splash screens and default static OG tags in app.html.

Testing / Validation

  • Verified that JSON-based NIP-11 relays generate accurate rich previews.
  • Verified that HTML-only relays accurately trigger the fallback parser to extract the <title> and icons.
  • Simulated timeouts and caching to ensure the main server doesn't hang if a relay is unresponsive.
  • Passed all Svelte compiler type-checks and linting (pnpm run check).

Closes #131

## Description This PR implements dynamic, server-side invite link previews for Flotilla Spaces. By introducing a lightweight Node.js server (`server.js`) to serve the built application, we can now dynamically intercept `/join` routes and inject rich OpenGraph (OG) and Twitter card metadata. Previously, invite links to spaces resulted in generic, static previews. Now, when a user shares a space invite link (e.g., `.../join?r=relay.scuttle.works&c=...`), the server will probe the relay's metadata on the fly and populate the preview card with the relay's specific name, description, and icon. ### Key Changes * **Dynamic Invite Previews:** Created `server.js` to serve the frontend while dynamically generating and injecting SEO/OG metadata for `/join` invite URLs. * **NIP-11 Metadata Probing:** Added a fast, cached fetching mechanism to retrieve a relay's metadata in JSON format (`application/nostr+json`). * **HTML Metadata Fallback:** Implemented robust fallback extraction logic for relays that return standard HTML landing pages instead of JSON (such as `relay.scuttle.works`). The server safely parses the HTML text to extract `<title>`, `<meta description>`, and various icon links (`og:image`, `twitter:image`, or `<link rel="icon">`). * **Case-Insensitive Regex:** Hardened the HTML extraction to correctly match HTML tags in a case-insensitive manner, ensuring high reliability across various custom relay setups. * **Deployment Config:** Updated `package.json` and the `Dockerfile` to use `node server.js` as the main start command instead of a static server. * **Visual Assets Updates:** Updated several Android/iOS splash screens and default static OG tags in `app.html`. ### Testing / Validation - [x] Verified that JSON-based NIP-11 relays generate accurate rich previews. - [x] Verified that HTML-only relays accurately trigger the fallback parser to extract the `<title>` and icons. - [x] Simulated timeouts and caching to ensure the main server doesn't hang if a relay is unresponsive. - [x] Passed all Svelte compiler type-checks and linting (`pnpm run check`). Closes #131
hodlbod requested changes 2026-04-24 15:33:14 +00:00
hodlbod left a comment
Owner

I think we're doing way too much manually here, look into using hono.js for the server so that we can serve static files more simply without faffing around with mime types. The only file we need to modify is index.html, so see if you can add middleware that does that. Also, use an html parsing library like cheerio to modify the html rather than regular expressions. I believe cheerio will handle sanitization too. Use welshman library functions for fetching relay information.

I think we're doing way too much manually here, look into using hono.js for the server so that we can serve static files more simply without faffing around with mime types. The only file we need to modify is index.html, so see if you can add middleware that does that. Also, use an html parsing library like cheerio to modify the html rather than regular expressions. I believe cheerio will handle sanitization too. Use welshman library functions for fetching relay information.
hodlbod reviewed 2026-04-25 13:21:47 +00:00
server.js Outdated
@@ -0,0 +468,4 @@
if (existingHead.length > 0) {
return existingHead
}
Owner

This PR is full of just in case things like this, why do we need so much verbose stuff when we have full control over the html template? Just assume it's there, because it is. This would cut the PR in half at least.

This PR is full of just in case things like this, why do we need so much verbose stuff when we have full control over the html template? Just assume it's there, because it is. This would cut the PR in half at least.
Khushvendra marked this conversation as resolved
server.js Outdated
@@ -0,0 +536,4 @@
}
const renderIndex = async requestUrl => {
if (!isJoinInvitePath(requestUrl.pathname)) {
Owner

The invite path isn't the only one we need to render. This file should cover pretty much anything: calendar events, polls, chat rooms, etc. We might not always be able to fetch the data, but we should try to render it. Continuing with the current approach will result in 2k LOC probably; if we import @welshman/app, it will make all of it much easier (and solve caching at the same time).

The invite path isn't the only one we need to render. This file should cover pretty much anything: calendar events, polls, chat rooms, etc. We might not always be able to fetch the data, but we should try to render it. Continuing with the current approach will result in 2k LOC probably; if we import @welshman/app, it will make all of it much easier (and solve caching at the same time).
Author
Contributor

if we import @welshman/app, it will make all of it much easier (and solve caching at the same time).

i have tried this, did decrease the LOC by ~75%. Also have made the corresponding changes according to you feed back. Lmk if you think there could be more improvements?

> if we import @welshman/app, it will make all of it much easier (and solve caching at the same time). i have tried this, did decrease the LOC by ~75%. Also have made the corresponding changes according to you feed back. Lmk if you think there could be more improvements?
Khushvendra marked this conversation as resolved
Author
Contributor

@hodlbod Does this align with what you mentioned?

@hodlbod Does this align with what you mentioned?
hodlbod reviewed 2026-04-29 16:08:45 +00:00
hodlbod left a comment
Owner

Looks better, but still a ways to go. See comments above.

Looks better, but still a ways to go. See comments above.
server.js Outdated
@@ -0,0 +34,4 @@
const DEFAULT_PLATFORM_DESCRIPTION =
process.env.VITE_PLATFORM_DESCRIPTION ||
TEMPLATE_DOCUMENT('meta[name="description"]').attr("content") ||
"Flotilla is nostr - for communities."
Owner

These will always be defined (because .env is checked in to version control), no need for a fallback

These will always be defined (because .env is checked in to version control), no need for a fallback
Khushvendra marked this conversation as resolved
server.js Outdated
@@ -0,0 +75,4 @@
if (parts.length >= 2) {
relayParam = decodeRelay(parts[1])
}
}
Owner

This routing logic is still incomplete and is pretty brittle. We should do something like this:

const routes = , [
  [/^\/join\/?$/, getMetadataForInvite],
  [/^\/spaces\/(RELAY_REGEX)\/?$/, getMetadataForSpace],
  [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/?$/, getMetadataForRoom],
  [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/?$/, getMetadataForCalendar],
  [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/(ADDRESS_REGEX)\/?$/, getMetadataForCalendarEvent],
]
const getMetadataForRoute = (url: URL) => {
  for (const [regex, getMetadata] of routes) {
    const match = url.pathname.match(regex)
    
    if (match) {
      return getMetadata(url, match)
    }
  }
}
const meta = getMetadataForRoute(requestUrl)

This way it's clear which function is responsible for which route. Common utilities can be factored out (e.g. relay fetching, relay title generation, etc).

This routing logic is still incomplete and is pretty brittle. We should do something like this: ```typescript const routes = , [ [/^\/join\/?$/, getMetadataForInvite], [/^\/spaces\/(RELAY_REGEX)\/?$/, getMetadataForSpace], [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/?$/, getMetadataForRoom], [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/?$/, getMetadataForCalendar], [/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/(ADDRESS_REGEX)\/?$/, getMetadataForCalendarEvent], ] const getMetadataForRoute = (url: URL) => { for (const [regex, getMetadata] of routes) { const match = url.pathname.match(regex) if (match) { return getMetadata(url, match) } } } const meta = getMetadataForRoute(requestUrl) ``` This way it's clear which function is responsible for which route. Common utilities can be factored out (e.g. relay fetching, relay title generation, etc).
Khushvendra marked this conversation as resolved
server.js Outdated
@@ -0,0 +124,4 @@
}
} catch (err) {
return undefined
}
Owner

We should not be swallowing errors, add a console.error statement here

We should not be swallowing errors, add a console.error statement here
Khushvendra marked this conversation as resolved
Author
Contributor

@hodlbod Addressed the issues you pointed out, also i swapped out fetchRelay with loadRelay (also imported directly from @welshman/app)

@hodlbod Addressed the issues you pointed out, also i swapped out fetchRelay with loadRelay (also imported directly from @welshman/app)
hodlbod added 4 commits 2026-05-04 21:02:47 +00:00
hodlbod force-pushed issue/131-invite-link-preview from f938bec632 to faf1b02a2c 2026-05-04 21:02:47 +00:00 Compare
hodlbod merged commit bbbc6f7363 into dev 2026-05-04 21:02:57 +00:00
hodlbod deleted branch issue/131-invite-link-preview 2026-05-04 21:02:57 +00:00
Sign in to join this conversation.