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
|
||||
WORKDIR /app
|
||||
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
|
||||
USER node
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
mkdir ./mount
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@
|
|||
"bump": "node scripts/release/bump.mjs",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"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:ui": "playwright test --ui",
|
||||
"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",
|
||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||
"format:all": "prettier --write src",
|
||||
|
|
|
|||
213
server.js
213
server.js
|
|
@ -5,11 +5,10 @@ import {fileURLToPath} from "node:url"
|
|||
import "dotenv/config"
|
||||
import {serve} from "@hono/node-server"
|
||||
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 {createPreview} from "./server/preview.js"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
|
|
@ -27,202 +26,6 @@ try {
|
|||
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()
|
||||
|
||||
// Only allow GET and HEAD requests
|
||||
|
|
@ -261,16 +64,8 @@ app.use(
|
|||
}),
|
||||
)
|
||||
|
||||
// SPA fallback for routes that don't match static files
|
||||
app.get("*", async context => {
|
||||
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",
|
||||
})
|
||||
})
|
||||
// A tld looks like a file extension, so a client route can arrive looking like a missing asset.
|
||||
app.route("/", createPreview(TEMPLATE_HTML))
|
||||
|
||||
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({
|
||||
// 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: {
|
||||
noExternal: true, // bundle every dependency
|
||||
},
|
||||
build: {
|
||||
target: 'node24', // match your Node.js version
|
||||
outDir: 'build-server',
|
||||
emptyOutDir: false, // don't wipe the frontend build output
|
||||
ssr: true, // tells Vite this is a server-side build
|
||||
minify: 'esbuild', // minify the output
|
||||
target: "node24", // match your Node.js version
|
||||
outDir: "build-server",
|
||||
emptyOutDir: false, // don't wipe the frontend build output
|
||||
ssr: true, // tells Vite this is a server-side build
|
||||
minify: "esbuild", // minify the output
|
||||
lib: {
|
||||
entry: 'server.js', // your server entry point
|
||||
formats: ['es'],
|
||||
fileName: () => 'server.js',
|
||||
entry: "server.js", // your server entry point
|
||||
formats: ["es"],
|
||||
fileName: () => "server.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
// Externalize only Node.js built-ins, bundle everything else
|
||||
external: [
|
||||
...builtinModules,
|
||||
...builtinModules.map(m => `node:${m}`),
|
||||
],
|
||||
external: [...builtinModules, ...builtinModules.map(m => `node:${m}`)],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue