Render a link preview per route, from the entity its url names (#654)
This commit is contained in:
parent
6a3da63b00
commit
9fd29db305
8 changed files with 495 additions and 226 deletions
|
|
@ -26,7 +26,8 @@ FROM node:24-slim AS production
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/build /app/build
|
COPY --from=builder /app/build /app/build
|
||||||
COPY --from=builder /app/build-server/server.js /app/server.js
|
# The bundle splits, and the chunks it imports at runtime sit beside it.
|
||||||
|
COPY --from=builder /app/build-server/ /app/
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
USER node
|
USER node
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
|
||||||
|
|
@ -286,7 +286,9 @@ Or, if you prefer to use a container:
|
||||||
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
|
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
Either one serves the build through `server.js`, which fills in the link preview for each page from the space, person or note its url names.
|
||||||
|
|
||||||
|
Alternatively, you can copy the build files into a directory of your choice and serve it yourself, in which case every page gets the platform's own preview:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir ./mount
|
mkdir ./mount
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,11 @@
|
||||||
"bump": "node scripts/release/bump.mjs",
|
"bump": "node scripts/release/bump.mjs",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/desktop/*.mjs scripts/release && eslint src e2e packages fdroid capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts",
|
"lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts vite.config.server.ts server.js server electron/*.ts electron/electron-builder.config.mjs scripts/desktop/*.mjs scripts/release && eslint src e2e packages fdroid capacitor.config.ts vite.config.server.ts server.js server electron/*.ts electron/electron-builder.config.mjs scripts",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"test:ui": "playwright test --ui",
|
"test:ui": "playwright test --ui",
|
||||||
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
|
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
|
||||||
|
"test:server": "node --test --test-force-exit server/test/*.test.mjs",
|
||||||
"test:desktop-storage": "node --test packages/desktop-secure-storage/test/*.test.mjs",
|
"test:desktop-storage": "node --test packages/desktop-secure-storage/test/*.test.mjs",
|
||||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||||
"format:all": "prettier --write src",
|
"format:all": "prettier --write src",
|
||||||
|
|
|
||||||
213
server.js
213
server.js
|
|
@ -5,11 +5,10 @@ import {fileURLToPath} from "node:url"
|
||||||
import "dotenv/config"
|
import "dotenv/config"
|
||||||
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 {App, Relays} from "@welshman/app"
|
|
||||||
import {normalizeRelayUrl} from "@welshman/util"
|
|
||||||
import {load} from "cheerio"
|
|
||||||
import {Hono} from "hono"
|
import {Hono} from "hono"
|
||||||
|
|
||||||
|
import {createPreview} from "./server/preview.js"
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
|
@ -27,202 +26,6 @@ try {
|
||||||
process.exit(1)
|
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 relays = new App().use(Relays)
|
|
||||||
|
|
||||||
const fetchRelayMeta = async relayUrl => {
|
|
||||||
if (!relayUrl) return undefined
|
|
||||||
try {
|
|
||||||
return await relays.load(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 = relayMetadata.displayUrl()
|
|
||||||
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.display()
|
|
||||||
|
|
||||||
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()
|
const app = new Hono()
|
||||||
|
|
||||||
// Only allow GET and HEAD requests
|
// Only allow GET and HEAD requests
|
||||||
|
|
@ -261,16 +64,8 @@ app.use(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// SPA fallback for routes that don't match static files
|
// A tld looks like a file extension, so a client route can arrive looking like a missing asset.
|
||||||
app.get("*", async context => {
|
app.route("/", createPreview(TEMPLATE_HTML))
|
||||||
const requestUrl = requestUrlFromContext(context)
|
|
||||||
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(
|
serve(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
379
server/preview.js
Normal file
379
server/preview.js
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import {Hono} from "hono"
|
||||||
|
import {load} from "cheerio"
|
||||||
|
import {ParsedType, parse, renderAsText, summarize} from "@welshman/content"
|
||||||
|
import {MINUTE, ellipsize, first, identity, int, ms, sleep, tryCatch} from "@welshman/lib"
|
||||||
|
import {displayRelayUrl, getIdFilters, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {
|
||||||
|
Article,
|
||||||
|
Classified,
|
||||||
|
Poll,
|
||||||
|
Thread,
|
||||||
|
TimeEvent,
|
||||||
|
ZapGoal,
|
||||||
|
displayPubkey,
|
||||||
|
} from "@welshman/domain"
|
||||||
|
import {Domain, Network, Profiles, Relays, Rooms, createApp, makeRoomKey} from "@welshman/app"
|
||||||
|
|
||||||
|
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
|
||||||
|
|
||||||
|
const fromCsv = value => (value || "").split(",").filter(identity)
|
||||||
|
|
||||||
|
const defaultRelays = fromCsv(process.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl)
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
getDefaultRelays: () => defaultRelays,
|
||||||
|
getIndexerRelays: () => fromCsv(process.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const oneLine = text => ellipsize((text || "").replace(/\s+/g, " ").trim(), 200)
|
||||||
|
|
||||||
|
// A mention reads as a name rather than as bech32, so whoever is mentioned has to be loaded too.
|
||||||
|
const readEvent = async event => {
|
||||||
|
if (event) {
|
||||||
|
const parsed = parse(event)
|
||||||
|
const mentions = parsed.filter(node => node.type === ParsedType.Profile)
|
||||||
|
|
||||||
|
await Promise.all(mentions.map(node => app.use(Profiles).load(node.value.pubkey)))
|
||||||
|
|
||||||
|
const getName = node =>
|
||||||
|
node.type === ParsedType.Profile
|
||||||
|
? app.use(Profiles).get(node.value.pubkey)?.display()
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return oneLine(renderAsText(summarize(parsed, getName)).toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodePubkey = entity => {
|
||||||
|
const decoded = tryCatch(() => nip19.decode(entity))
|
||||||
|
|
||||||
|
if (decoded?.type === "npub") {
|
||||||
|
return decoded.data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded?.type === "nprofile") {
|
||||||
|
return decoded.data.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeEvent = entity => {
|
||||||
|
const decoded = tryCatch(() => nip19.decode(entity))
|
||||||
|
|
||||||
|
if (decoded?.type === "nevent") {
|
||||||
|
return {id: decoded.data.id, relays: decoded.data.relays || []}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded?.type === "note") {
|
||||||
|
return {id: decoded.data, relays: []}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded?.type === "naddr") {
|
||||||
|
const {kind, pubkey, identifier, relays = []} = decoded.data
|
||||||
|
|
||||||
|
return {id: [kind, pubkey, identifier].join(":"), relays}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A relay that never answers its nip-11 request would otherwise hold the page open indefinitely.
|
||||||
|
const loadSpace = async url => {
|
||||||
|
const relay = await Promise.race([app.use(Relays).load(url), sleep(ms(2))])
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
name: relay?.display() || displayRelayUrl(url),
|
||||||
|
description: relay?.description,
|
||||||
|
image: relay?.icon || relay?.banner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEvent = (idOrAddress, relays) =>
|
||||||
|
app
|
||||||
|
.use(Network)
|
||||||
|
.loadLenient({filters: getIdFilters([idOrAddress]), relays})
|
||||||
|
.then(first)
|
||||||
|
|
||||||
|
// One describer per route, each naming the space or the platform whatever fails to load.
|
||||||
|
|
||||||
|
const describePerson = async entity => {
|
||||||
|
const pubkey = decodePubkey(entity)
|
||||||
|
|
||||||
|
if (pubkey) {
|
||||||
|
const profile = await app.use(Profiles).load(pubkey)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${profile?.display() || displayPubkey(pubkey)} on ${PLATFORM_NAME}`,
|
||||||
|
description: oneLine(profile?.about()) || `Someone on ${PLATFORM_NAME}.`,
|
||||||
|
image: profile?.picture(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const describeNote = async entity => {
|
||||||
|
const pointer = decodeEvent(entity)
|
||||||
|
|
||||||
|
if (pointer) {
|
||||||
|
const event = await loadEvent(pointer.id, [...pointer.relays, ...defaultRelays])
|
||||||
|
const author = event && (await app.use(Profiles).load(event.pubkey))
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: author ? `A note by ${author.display()}` : `A note on ${PLATFORM_NAME}`,
|
||||||
|
description: (await readEvent(event)) || `Somebody's note on ${PLATFORM_NAME}.`,
|
||||||
|
image: author?.picture(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const describeSpace = space => ({
|
||||||
|
title: `${space.name} on ${PLATFORM_NAME}`,
|
||||||
|
description: space.description || `${space.name} is a community on ${PLATFORM_NAME}.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const describeItem = (factory, noun) => async (space, params) => {
|
||||||
|
const event = await loadEvent(params.id, [space.url])
|
||||||
|
const title = event && app.use(Domain).reader(factory)(event).title()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title ? `${title} in ${space.name}` : `${noun} in ${space.name}`,
|
||||||
|
description: (await readEvent(event)) || space.description || `${noun} in ${space.name}.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const describeRoom = async (space, params) => {
|
||||||
|
const room = await app.use(Rooms).load(makeRoomKey(space.url, params.h))
|
||||||
|
const name = room?.meta?.name()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: name ? `${name} in ${space.name}` : `A room in ${space.name}`,
|
||||||
|
description: oneLine(room?.meta?.about()) || space.description || `Chat in ${space.name}.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = context => {
|
||||||
|
const url = 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") {
|
||||||
|
url.protocol = `${forwardedProto}:`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forwardedHost) {
|
||||||
|
// The host setter leaves a port already on the url alone unless the new host carries one.
|
||||||
|
url.port = ""
|
||||||
|
url.host = forwardedHost
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const injectMetadata = (template, metadata, url) => {
|
||||||
|
const $ = load(template)
|
||||||
|
|
||||||
|
$("title").text(metadata.title)
|
||||||
|
$('meta[property="og:title"]').attr("content", metadata.title)
|
||||||
|
$('meta[name="twitter:title"]').attr("content", metadata.title)
|
||||||
|
$('meta[name="description"]').attr("content", metadata.description)
|
||||||
|
$('meta[property="og:description"]').attr("content", metadata.description)
|
||||||
|
$('meta[name="twitter:description"]').attr("content", metadata.description)
|
||||||
|
$('meta[property="og:image"]').attr("content", metadata.image)
|
||||||
|
$('meta[name="twitter:image"]').attr("content", metadata.image)
|
||||||
|
$('meta[property="og:url"]').attr("content", url.toString())
|
||||||
|
$('meta[name="twitter:url"]').attr("content", url.toString())
|
||||||
|
$('meta[name="twitter:site"]').attr("content", url.origin)
|
||||||
|
$('link[rel="canonical"]').attr("href", url.toString())
|
||||||
|
|
||||||
|
return $.html()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map()
|
||||||
|
|
||||||
|
// A preview costs a round trip to a relay, and a crawler asks for the same url several times.
|
||||||
|
const remember = async (key, describe) => {
|
||||||
|
const cached = cache.get(key)
|
||||||
|
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await describe()
|
||||||
|
|
||||||
|
if (cache.size > 500) {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(key, {metadata, expiresAt: Date.now() + ms(int(5, MINUTE))})
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPreview = template => {
|
||||||
|
const renderTemplate = context => context.html(template, 200, {"Cache-Control": "no-cache"})
|
||||||
|
|
||||||
|
const render = describe => async context => {
|
||||||
|
const url = requestUrl(context)
|
||||||
|
const metadata = await remember(url.pathname + url.search, () =>
|
||||||
|
describe(url, context.req.param()),
|
||||||
|
).catch(error => console.error(`Failed to build a preview for ${url.pathname}:`, error))
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
const image = metadata.image || new URL("/maskable-icon-512x512.png", url.origin).toString()
|
||||||
|
|
||||||
|
return context.html(injectMetadata(template, {...metadata, image}, url), 200, {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderTemplate(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A space relay answers nothing without auth, so most of these fall back to its nip-11 profile.
|
||||||
|
const renderSpace = describe =>
|
||||||
|
render(async (url, params) => {
|
||||||
|
const relayUrl = tryCatch(() => normalizeRelayUrl(params.relay))
|
||||||
|
|
||||||
|
if (relayUrl) {
|
||||||
|
const space = await loadSpace(relayUrl)
|
||||||
|
|
||||||
|
return {image: space.image, ...(await describe(space, params))}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const preview = new Hono()
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/join",
|
||||||
|
render(async url => {
|
||||||
|
const relayUrl = tryCatch(() => normalizeRelayUrl(url.searchParams.get("r")))
|
||||||
|
|
||||||
|
if (relayUrl) {
|
||||||
|
const space = await loadSpace(relayUrl)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Join ${space.name} on ${PLATFORM_NAME}`,
|
||||||
|
description:
|
||||||
|
space.description || `You are invited to join ${space.name} on ${PLATFORM_NAME}.`,
|
||||||
|
image: space.image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/people/:npub",
|
||||||
|
render((url, params) => describePerson(params.npub)),
|
||||||
|
)
|
||||||
|
preview.get(
|
||||||
|
"/notes/:nevent",
|
||||||
|
render((url, params) => describeNote(params.nevent)),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/:entity{(npub|nprofile|nevent|note|naddr)1[a-z0-9]+}",
|
||||||
|
render(
|
||||||
|
async (url, params) => (await describePerson(params.entity)) || describeNote(params.entity),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get("/spaces/create", renderTemplate)
|
||||||
|
preview.get("/spaces/:relay", renderSpace(describeSpace))
|
||||||
|
preview.get("/spaces/:relay/admin", renderSpace(describeSpace))
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/about",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `About ${space.name}`,
|
||||||
|
description: space.description || `What ${space.name} is and who it is for.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/chat",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `Chat in ${space.name}`,
|
||||||
|
description: space.description || `Everybody in ${space.name} talks here.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/directory",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `Members of ${space.name}`,
|
||||||
|
description: `Who is in ${space.name}, and where they are.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/library",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `Library of ${space.name}`,
|
||||||
|
description: `What ${space.name} has collected and kept.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/calendar",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `Calendar of ${space.name}`,
|
||||||
|
description: `What ${space.name} has coming up.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/threads",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `Threads in ${space.name}`,
|
||||||
|
description: `Longer conversations in ${space.name}.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/polls",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `Polls in ${space.name}`,
|
||||||
|
description: `What ${space.name} is deciding.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/goals",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `Goals of ${space.name}`,
|
||||||
|
description: `What ${space.name} is raising money for.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/classifieds",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `Classifieds in ${space.name}`,
|
||||||
|
description: `What ${space.name} is buying and selling.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get(
|
||||||
|
"/spaces/:relay/articles",
|
||||||
|
renderSpace(space => ({
|
||||||
|
title: `Articles in ${space.name}`,
|
||||||
|
description: `Long-form writing from ${space.name}.`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview.get("/spaces/:relay/articles/create", renderTemplate)
|
||||||
|
preview.get("/spaces/:relay/calendar/:id", renderSpace(describeItem(TimeEvent, "An event")))
|
||||||
|
preview.get("/spaces/:relay/threads/:id", renderSpace(describeItem(Thread, "A thread")))
|
||||||
|
preview.get("/spaces/:relay/polls/:id", renderSpace(describeItem(Poll, "A poll")))
|
||||||
|
preview.get("/spaces/:relay/goals/:id", renderSpace(describeItem(ZapGoal, "A goal")))
|
||||||
|
preview.get("/spaces/:relay/classifieds/:id", renderSpace(describeItem(Classified, "A listing")))
|
||||||
|
preview.get("/spaces/:relay/articles/:id", renderSpace(describeItem(Article, "An article")))
|
||||||
|
preview.get("/spaces/:relay/:h", renderSpace(describeRoom))
|
||||||
|
|
||||||
|
// The built index.html already describes the platform, which is all any other route is about.
|
||||||
|
preview.get("*", renderTemplate)
|
||||||
|
|
||||||
|
return preview
|
||||||
|
}
|
||||||
76
server/test/preview.test.mjs
Normal file
76
server/test/preview.test.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {describe, test} from "node:test"
|
||||||
|
import {load} from "cheerio"
|
||||||
|
|
||||||
|
process.env.VITE_PLATFORM_NAME = "Flotilla"
|
||||||
|
process.env.VITE_DEFAULT_RELAYS = ""
|
||||||
|
process.env.VITE_INDEXER_RELAYS = ""
|
||||||
|
|
||||||
|
const {createPreview} = await import("../preview.js")
|
||||||
|
|
||||||
|
const template = `<!doctype html>
|
||||||
|
<html><head>
|
||||||
|
<title>Flotilla</title>
|
||||||
|
<meta name="description" content="Digital communities for free people." />
|
||||||
|
<meta property="og:title" content="Flotilla" />
|
||||||
|
<meta property="og:description" content="Digital communities for free people." />
|
||||||
|
<meta property="og:image" content="" />
|
||||||
|
<link rel="canonical" href="https://app.flotilla.social" />
|
||||||
|
</head><body></body></html>`
|
||||||
|
|
||||||
|
// Nothing resolves this host, so every route falls back to what its url alone says.
|
||||||
|
const space = "nowhere.invalid"
|
||||||
|
|
||||||
|
const preview = createPreview(template)
|
||||||
|
|
||||||
|
const request = async path => {
|
||||||
|
const $ = load(await (await preview.request("https://app.flotilla.social" + path)).text())
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: $("title").text(),
|
||||||
|
description: $('meta[property="og:description"]').attr("content"),
|
||||||
|
image: $('meta[property="og:image"]').attr("content"),
|
||||||
|
canonical: $('link[rel="canonical"]').attr("href"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("preview metadata", () => {
|
||||||
|
test("names the space in an invite", async () => {
|
||||||
|
const {title, description} = await request(`/join?r=${space}`)
|
||||||
|
|
||||||
|
assert.equal(title, `Join ${space} on Flotilla`)
|
||||||
|
assert.equal(description, `You are invited to join ${space} on Flotilla.`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("describes a space", async () => {
|
||||||
|
const {title, description, canonical} = await request(`/spaces/${space}`)
|
||||||
|
|
||||||
|
assert.equal(title, `${space} on Flotilla`)
|
||||||
|
assert.equal(description, `${space} is a community on Flotilla.`)
|
||||||
|
assert.equal(canonical, `https://app.flotilla.social/spaces/${space}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("describes each section of a space differently", async () => {
|
||||||
|
assert.equal((await request(`/spaces/${space}/threads`)).title, `Threads in ${space}`)
|
||||||
|
assert.equal((await request(`/spaces/${space}/calendar`)).title, `Calendar of ${space}`)
|
||||||
|
assert.equal((await request(`/spaces/${space}/directory`)).title, `Members of ${space}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to the noun a route is about", async () => {
|
||||||
|
assert.equal((await request(`/spaces/${space}/threads/abc`)).title, `A thread in ${space}`)
|
||||||
|
assert.equal((await request(`/spaces/${space}/calendar/abc`)).title, `An event in ${space}`)
|
||||||
|
assert.equal((await request(`/spaces/${space}/whatever`)).title, `A room in ${space}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to the platform image", async () => {
|
||||||
|
const {image} = await request(`/spaces/${space}`)
|
||||||
|
|
||||||
|
assert.equal(image, "https://app.flotilla.social/maskable-icon-512x512.png")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("leaves the platform's own pages alone", async () => {
|
||||||
|
for (const path of ["/", "/settings/profile", "/spaces/create", "/people/not-an-npub"]) {
|
||||||
|
assert.equal((await request(path)).title, "Flotilla")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
2
server/websocket.js
Normal file
2
server/websocket.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Bundling ws breaks its buffer helpers ("o.mask is not a function"), and node has its own client.
|
||||||
|
export default globalThis.WebSocket
|
||||||
|
|
@ -1,27 +1,40 @@
|
||||||
import { defineConfig } from "vite"
|
import {builtinModules} from "module"
|
||||||
import { builtinModules } from 'module'
|
import {fileURLToPath} from "url"
|
||||||
|
import {config} from "dotenv"
|
||||||
|
import {defineConfig} from "vite"
|
||||||
|
|
||||||
|
config({path: ".env.local"})
|
||||||
|
config({path: ".env"})
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// The production image has no .env beside the bundle, so branding travels inside the bundle.
|
||||||
|
define: Object.fromEntries(
|
||||||
|
Object.keys(process.env)
|
||||||
|
.filter(key => key.startsWith("VITE_"))
|
||||||
|
.map(key => [`process.env.${key}`, JSON.stringify(process.env[key])]),
|
||||||
|
),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"isomorphic-ws": fileURLToPath(new URL("server/websocket.js", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: true, // bundle every dependency
|
noExternal: true, // bundle every dependency
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'node24', // match your Node.js version
|
target: "node24", // match your Node.js version
|
||||||
outDir: 'build-server',
|
outDir: "build-server",
|
||||||
emptyOutDir: false, // don't wipe the frontend build output
|
emptyOutDir: false, // don't wipe the frontend build output
|
||||||
ssr: true, // tells Vite this is a server-side build
|
ssr: true, // tells Vite this is a server-side build
|
||||||
minify: 'esbuild', // minify the output
|
minify: "esbuild", // minify the output
|
||||||
lib: {
|
lib: {
|
||||||
entry: 'server.js', // your server entry point
|
entry: "server.js", // your server entry point
|
||||||
formats: ['es'],
|
formats: ["es"],
|
||||||
fileName: () => 'server.js',
|
fileName: () => "server.js",
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
// Externalize only Node.js built-ins, bundle everything else
|
// Externalize only Node.js built-ins, bundle everything else
|
||||||
external: [
|
external: [...builtinModules, ...builtinModules.map(m => `node:${m}`)],
|
||||||
...builtinModules,
|
|
||||||
...builtinModules.map(m => `node:${m}`),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue