Render a link preview per route, from the entity its url names (#654)

This commit is contained in:
Coracle-Bot 2026-09-25 17:12:58 +00:00 committed by hodlbod
parent 6a3da63b00
commit 9fd29db305
8 changed files with 495 additions and 226 deletions

View file

@ -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"]

View file

@ -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

View file

@ -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
View file

@ -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
View 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
}

View 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
View 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

View file

@ -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}`)],
},
},
})