diff --git a/Dockerfile b/Dockerfile
index 0a5b6c74..ed71b94f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,7 +26,12 @@ FROM node:20-alpine
WORKDIR /app
-# Copy only the built output - no source, no .env, no dev deps
+# Install production dependencies needed by the Node server runtime
+RUN npm install -g pnpm@10.33.0
+COPY package.json pnpm-lock.yaml ./
+RUN pnpm i --prod --frozen-lockfile --ignore-scripts
+
+# Copy only the built output and server source - no app source, no .env, no dev deps
COPY --from=builder /app/build ./build
COPY --from=builder /app/server.js ./server.js
diff --git a/package.json b/package.json
index 7f20624a..b5327329 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
"@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2",
+ "@hono/node-server": "^2.0.0",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.3",
"@poppanator/sveltekit-svg": "^4.2.1",
@@ -81,12 +82,14 @@
"@welshman/signer": "^0.8.13",
"@welshman/store": "^0.8.13",
"@welshman/util": "^0.8.13",
+ "cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1",
"fuse.js": "^7.1.0",
+ "hono": "^4.12.15",
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e02625b7..03949fab 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -62,6 +62,9 @@ importers:
'@getalby/sdk':
specifier: ^5.1.2
version: 5.1.2(typescript@5.9.3)
+ '@hono/node-server':
+ specifier: ^2.0.0
+ version: 2.0.0(hono@4.12.15)
'@noble/curves':
specifier: ^1.9.7
version: 1.9.7
@@ -122,6 +125,9 @@ importers:
'@welshman/util':
specifier: ^0.8.13
version: 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
+ cheerio:
+ specifier: ^1.2.0
+ version: 1.2.0
compressorjs-next:
specifier: ^1.1.2
version: 1.1.2
@@ -140,6 +146,9 @@ importers:
fuse.js:
specifier: ^7.1.0
version: 7.1.0
+ hono:
+ specifier: ^4.12.15
+ version: 4.12.15
husky:
specifier: ^9.1.7
version: 9.1.7
@@ -1096,6 +1105,12 @@ packages:
resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==}
engines: {node: '>=14'}
+ '@hono/node-server@2.0.0':
+ resolution: {integrity: sha512-n3GfHwwCvHCkGmOwKfxUPOlbfzuO64Sbc5XC4NGPIXxkuOnJrdgExdRKmHfF924r914WRJPT397GdqLvdYTeyQ==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ hono: ^4
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -2465,6 +2480,13 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ cheerio-select@2.1.0:
+ resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
+
+ cheerio@1.2.0:
+ resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
+ engines: {node: '>=20.18.1'}
+
chevrotain@7.1.1:
resolution: {integrity: sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==}
@@ -2830,6 +2852,9 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+ encoding-sniffer@0.2.1:
+ resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
+
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
@@ -2841,6 +2866,14 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
+ entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -3234,6 +3267,10 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
+ hono@4.12.15:
+ resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==}
+ engines: {node: '>=16.9.0'}
+
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -3241,6 +3278,9 @@ packages:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
+ htmlparser2@10.1.0:
+ resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
+
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
@@ -3249,6 +3289,10 @@ packages:
ico-endec@0.1.6:
resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==}
+ iconv-lite@0.6.3:
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+ engines: {node: '>=0.10.0'}
+
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -4015,6 +4059,15 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
+ parse5-htmlparser2-tree-adapter@7.1.0:
+ resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
+
+ parse5-parser-stream@7.1.2:
+ resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
+
+ parse5@7.3.0:
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+
path-exists@3.0.0:
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
@@ -4456,6 +4509,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
sax@1.1.4:
resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==}
@@ -4891,6 +4947,10 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+ undici@7.25.0:
+ resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
+ engines: {node: '>=20.18.1'}
+
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'}
@@ -5009,6 +5069,15 @@ packages:
resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==}
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
+ whatwg-encoding@3.1.1:
+ resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
+ engines: {node: '>=18'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+
+ whatwg-mimetype@4.0.0:
+ resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
+ engines: {node: '>=18'}
+
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -6228,6 +6297,10 @@ snapshots:
transitivePeerDependencies:
- typescript
+ '@hono/node-server@2.0.0(hono@4.12.15)':
+ dependencies:
+ hono: 4.12.15
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -7642,6 +7715,29 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ cheerio-select@2.1.0:
+ dependencies:
+ boolbase: 1.0.0
+ css-select: 5.2.2
+ css-what: 6.2.2
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+
+ cheerio@1.2.0:
+ dependencies:
+ cheerio-select: 2.1.0
+ dom-serializer: 2.0.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ encoding-sniffer: 0.2.1
+ htmlparser2: 10.1.0
+ parse5: 7.3.0
+ parse5-htmlparser2-tree-adapter: 7.1.0
+ parse5-parser-stream: 7.1.2
+ undici: 7.25.0
+ whatwg-mimetype: 4.0.0
+
chevrotain@7.1.1:
dependencies:
regexp-to-ast: 0.5.0
@@ -8032,6 +8128,11 @@ snapshots:
emoji-regex@8.0.0: {}
+ encoding-sniffer@0.2.1:
+ dependencies:
+ iconv-lite: 0.6.3
+ whatwg-encoding: 3.1.1
+
enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
@@ -8041,6 +8142,10 @@ snapshots:
entities@4.5.0: {}
+ entities@6.0.1: {}
+
+ entities@7.0.1: {}
+
env-paths@2.2.1: {}
env-paths@3.0.0: {}
@@ -8550,16 +8655,29 @@ snapshots:
he@1.2.0: {}
+ hono@4.12.15: {}
+
hosted-git-info@2.8.9: {}
hosted-git-info@4.1.0:
dependencies:
lru-cache: 6.0.0
+ htmlparser2@10.1.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ entities: 7.0.1
+
husky@9.1.7: {}
ico-endec@0.1.6: {}
+ iconv-lite@0.6.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
idb@7.1.1: {}
idb@8.0.3: {}
@@ -9266,6 +9384,19 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
+ parse5-htmlparser2-tree-adapter@7.1.0:
+ dependencies:
+ domhandler: 5.0.3
+ parse5: 7.3.0
+
+ parse5-parser-stream@7.1.2:
+ dependencies:
+ parse5: 7.3.0
+
+ parse5@7.3.0:
+ dependencies:
+ entities: 6.0.1
+
path-exists@3.0.0: {}
path-exists@4.0.0: {}
@@ -9704,6 +9835,8 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
+ safer-buffer@2.1.2: {}
+
sax@1.1.4: {}
sax@1.4.4: {}
@@ -10229,6 +10362,8 @@ snapshots:
undici-types@7.16.0: {}
+ undici@7.25.0: {}
+
unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0:
@@ -10309,6 +10444,12 @@ snapshots:
dependencies:
sdp: 3.2.1
+ whatwg-encoding@3.1.1:
+ dependencies:
+ iconv-lite: 0.6.3
+
+ whatwg-mimetype@4.0.0: {}
+
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
diff --git a/server.js b/server.js
index 51cbfcc0..46ddf002 100644
--- a/server.js
+++ b/server.js
@@ -1,9 +1,14 @@
-import http from "node:http"
import path from "node:path"
-import {createReadStream} from "node:fs"
import {promises as fs} from "node:fs"
import {fileURLToPath} from "node:url"
+import {serve} from "@hono/node-server"
+import {serveStatic} from "@hono/node-server/serve-static"
+import {fetchRelay} from "@welshman/app"
+import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
+import {load} from "cheerio"
+import {Hono} from "hono"
+
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -30,35 +35,9 @@ const NEGATIVE_CACHE_TTL_MS = readPositiveInt(
2 * 60 * 1000,
)
-const MIME_TYPES = Object.freeze({
- ".avif": "image/avif",
- ".css": "text/css; charset=utf-8",
- ".gif": "image/gif",
- ".html": "text/html; charset=utf-8",
- ".ico": "image/x-icon",
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".js": "text/javascript; charset=utf-8",
- ".json": "application/json; charset=utf-8",
- ".map": "application/json; charset=utf-8",
- ".png": "image/png",
- ".svg": "image/svg+xml",
- ".txt": "text/plain; charset=utf-8",
- ".webmanifest": "application/manifest+json; charset=utf-8",
- ".webp": "image/webp",
- ".woff": "font/woff",
- ".woff2": "font/woff2",
-})
-
-const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
-
-const escapeHtml = value =>
- value
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'")
+const ALLOWED_METHODS = new Set(["GET", "HEAD"])
+const DEFAULT_IMAGE_PATH = "/maskable-icon-512x512.png"
+const HTML_HEADERS = {"Content-Type": "text/html; charset=utf-8"}
const truncate = (value, limit) => {
if (value.length <= limit) {
@@ -88,98 +67,41 @@ const sanitizeText = (value, limit) => {
const isRecord = value => Boolean(value) && typeof value === "object" && !Array.isArray(value)
-const readMetaContent = (html, key) => {
- const pattern = new RegExp(
- `]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*content=["']([^"']*)["'][^>]*>`,
- "i",
- )
- const match = html.match(pattern)
+const hasMetadata = metadata => Object.keys(metadata).length > 0
- return match?.[1]
-}
-
-const upsertTitle = (html, title) => {
- const escapedTitle = escapeHtml(title)
- const pattern = /
]*>.*?<\/title>/is
-
- if (pattern.test(html)) {
- return html.replace(pattern, `${escapedTitle}`)
- }
-
- return html.replace("", ` ${escapedTitle}\n `)
-}
-
-const upsertMetaTag = (html, key, content, attribute) => {
- const pattern = new RegExp(
- `]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`,
- "i",
- )
- const tag = ``
-
- if (pattern.test(html)) {
- return html.replace(pattern, tag)
- }
-
- return html.replace("", ` ${tag}\n `)
-}
-
-const upsertCanonical = (html, href) => {
- const pattern = /]*rel=["']canonical["'][^>]*>/i
- const tag = ``
-
- if (pattern.test(html)) {
- return html.replace(pattern, tag)
- }
-
- return html.replace("", ` ${tag}\n `)
-}
-
-const normalizeRelayInput = value => {
- const trimmed = value.trim()
-
- if (!trimmed) {
+const firstHeaderValue = value => {
+ if (typeof value !== "string") {
return undefined
}
- const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `wss://${trimmed}`
+ return sanitizeText(value.split(",")[0], 256)
+}
+
+const requestUrlFromContext = context => {
+ const requestUrl = new URL(context.req.url)
+ const forwardedProto = firstHeaderValue(context.req.header("x-forwarded-proto"))
+ const forwardedHost = firstHeaderValue(context.req.header("x-forwarded-host"))
try {
- const relayUrl = new URL(withScheme)
-
- if (!["ws:", "wss:", "http:", "https:"].includes(relayUrl.protocol)) {
- return undefined
+ if (forwardedProto === "http" || forwardedProto === "https") {
+ requestUrl.protocol = `${forwardedProto}:`
}
- relayUrl.hash = ""
- relayUrl.search = ""
-
- if (relayUrl.pathname !== "/") {
- relayUrl.pathname = relayUrl.pathname.replace(/\/+$/, "")
+ if (forwardedHost) {
+ requestUrl.host = forwardedHost
}
-
- return relayUrl.toString()
} catch {
- return undefined
+ return requestUrl
}
+
+ return requestUrl
}
-const relayToInfoUrl = relayUrl => {
+const absoluteUrlFromRequest = (requestUrl, value) => {
try {
- const relayHttpUrl = new URL(relayUrl)
-
- if (relayHttpUrl.protocol === "ws:") relayHttpUrl.protocol = "http:"
- if (relayHttpUrl.protocol === "wss:") relayHttpUrl.protocol = "https:"
-
- if (!["http:", "https:"].includes(relayHttpUrl.protocol)) {
- return undefined
- }
-
- relayHttpUrl.hash = ""
- relayHttpUrl.search = ""
-
- return relayHttpUrl.toString()
+ return new URL(value, requestUrl.origin).toString()
} catch {
- return undefined
+ return value
}
}
@@ -207,128 +129,188 @@ const normalizeImageUrl = (value, baseUrl) => {
}
}
-const decodeHtmlEntities = value =>
- value
- .replaceAll("&", "&")
- .replaceAll(""", '"')
- .replaceAll("'", "'")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
+const normalizeRelayInput = value => {
+ const trimmed = value.trim()
-const readHtmlAttribute = (tag, key) => {
- const pattern = new RegExp(`${escapeRegExp(key)}=["']([^"']*)["']`, "i")
- const match = tag.match(pattern)
+ if (!trimmed) {
+ return undefined
+ }
- return match?.[1]
-}
+ let relayInput = trimmed
-const readHtmlTagContent = (html, tag) => {
- const pattern = new RegExp(`<${escapeRegExp(tag)}[^>]*>([\\s\\S]*?)<\\/${escapeRegExp(tag)}>`, "i")
- const match = html.match(pattern)
+ try {
+ const parsed = new URL(trimmed)
- return match?.[1]
-}
-
-const readHtmlMetaContent = (html, key) => {
- const forwardPattern = new RegExp(
- `]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*content=["']([^"']*)["'][^>]*>`,
- "i",
- )
- const reversePattern = new RegExp(
- `]*content=["']([^"']*)["'][^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`,
- "i",
- )
-
- return html.match(forwardPattern)?.[1] || html.match(reversePattern)?.[1]
-}
-
-const readHtmlIconHref = html => {
- const links = html.match(/]*>/gi) || []
-
- for (const link of links) {
- const rel = readHtmlAttribute(link, "rel")?.toLowerCase() || ""
-
- if (!rel.includes("icon")) {
- continue
+ if (parsed.protocol === "http:") {
+ parsed.protocol = "ws:"
+ relayInput = parsed.toString()
}
- const href = readHtmlAttribute(link, "href")
+ if (parsed.protocol === "https:") {
+ parsed.protocol = "wss:"
+ relayInput = parsed.toString()
+ }
+ } catch {
+ relayInput = trimmed
+ }
+ if (!isRelayUrl(relayInput)) {
+ return undefined
+ }
+
+ try {
+ const relayUrl = new URL(normalizeRelayUrl(relayInput))
+
+ relayUrl.hash = ""
+ relayUrl.search = ""
+
+ return relayUrl.toString()
+ } catch {
+ return undefined
+ }
+}
+
+const relayToInfoUrl = relayUrl => {
+ try {
+ const relayHttpUrl = new URL(relayUrl)
+
+ if (relayHttpUrl.protocol === "ws:") {
+ relayHttpUrl.protocol = "http:"
+ } else if (relayHttpUrl.protocol === "wss:") {
+ relayHttpUrl.protocol = "https:"
+ } else {
+ return undefined
+ }
+
+ relayHttpUrl.hash = ""
+ relayHttpUrl.search = ""
+
+ return relayHttpUrl.toString()
+ } catch {
+ return undefined
+ }
+}
+
+const withTimeout = async (promise, timeoutMs) =>
+ new Promise(resolve => {
+ if (timeoutMs <= 0) {
+ resolve(undefined)
+ return
+ }
+
+ const timeout = setTimeout(() => resolve(undefined), timeoutMs)
+
+ promise
+ .then(resolve)
+ .catch(() => resolve(undefined))
+ .finally(() => clearTimeout(timeout))
+ })
+
+const findMeta = ($, key) => {
+ const normalizedKey = key.toLowerCase()
+
+ return $("meta")
+ .filter((_, element) => {
+ const meta = $(element)
+ const name = meta.attr("name")?.toLowerCase()
+ const property = meta.attr("property")?.toLowerCase()
+
+ return name === normalizedKey || property === normalizedKey
+ })
+ .first()
+}
+
+const readMetaContent = ($, key) => findMeta($, key).attr("content")
+
+const readIconHref = $ => {
+ let href
+
+ $("link[rel]").each((_, element) => {
if (href) {
- return href
- }
- }
-}
-
-const requestOrigin = request => {
- const forwardedProto = request.headers["x-forwarded-proto"]
- const forwardedHost = request.headers["x-forwarded-host"]
- const host =
- (typeof forwardedHost === "string" && forwardedHost.split(",")[0].trim()) ||
- request.headers.host ||
- "localhost"
- const protocol =
- (typeof forwardedProto === "string" && forwardedProto.split(",")[0].trim()) ||
- (request.socket.encrypted ? "https" : "http")
-
- return `${protocol}://${host}`
-}
-
-const absoluteUrlFromRequest = (requestUrl, value) => {
- try {
- return new URL(value, `${requestUrl.protocol}//${requestUrl.host}`).toString()
- } catch {
- return value
- }
-}
-
-const safeBuildPath = pathname => {
- let decodedPath = pathname
-
- try {
- decodedPath = decodeURIComponent(pathname)
- } catch {
- return undefined
- }
-
- const normalizedPath = path.posix.normalize(decodedPath)
- const routePath = normalizedPath === "/" ? "/index.html" : normalizedPath
- const resolvedPath = path.resolve(BUILD_DIR, `.${routePath}`)
-
- if (!resolvedPath.startsWith(BUILD_DIR + path.sep) && resolvedPath !== BUILD_DIR) {
- return undefined
- }
-
- return resolvedPath
-}
-
-const findStaticFile = async pathname => {
- const candidatePath = safeBuildPath(pathname)
-
- if (!candidatePath) {
- return undefined
- }
-
- try {
- const stats = await fs.stat(candidatePath)
-
- if (stats.isFile()) {
- return {filePath: candidatePath, size: stats.size}
+ return
}
- if (stats.isDirectory()) {
- const nestedIndex = path.join(candidatePath, "index.html")
- const indexStats = await fs.stat(nestedIndex)
+ const link = $(element)
+ const rel = link.attr("rel")?.toLowerCase() || ""
- if (indexStats.isFile()) {
- return {filePath: nestedIndex, size: indexStats.size}
- }
+ if (rel.includes("icon")) {
+ href = link.attr("href")
}
- } catch {
- return undefined
+ })
+
+ return href
+}
+
+const findLinkRel = ($, rel) =>
+ $("link[rel]")
+ .filter((_, element) => ($(element).attr("rel")?.toLowerCase() || "").includes(rel))
+ .first()
+
+const extractHtmlMetadata = (html, baseUrl) => {
+ const $ = load(html)
+ const name = sanitizeText($("title").first().text(), 80)
+ const description = sanitizeText(readMetaContent($, "description") || "", 180)
+ const icon = normalizeImageUrl(
+ readMetaContent($, "og:image") || readMetaContent($, "twitter:image") || readIconHref($) || "",
+ baseUrl,
+ )
+
+ return {
+ ...(name ? {name} : {}),
+ ...(description ? {description} : {}),
+ ...(icon ? {icon} : {}),
+ }
+}
+
+const normalizeRelayMetadata = (relayMetadata, relayUrl) => {
+ if (!isRecord(relayMetadata)) {
+ return {}
}
- return undefined
+ const infoUrl = relayToInfoUrl(relayUrl) || relayUrl
+ const name = sanitizeText(relayMetadata.name || relayMetadata.title, 80)
+ const description = sanitizeText(relayMetadata.description, 180)
+ const icon = normalizeImageUrl(
+ relayMetadata.icon || relayMetadata.picture || relayMetadata.image,
+ infoUrl,
+ )
+
+ return {
+ ...(name ? {name} : {}),
+ ...(description ? {description} : {}),
+ ...(icon ? {icon} : {}),
+ }
+}
+
+const fetchHtmlRelayMetadata = async (relayUrl, timeoutMs) => {
+ const infoUrl = relayToInfoUrl(relayUrl)
+
+ if (!infoUrl || timeoutMs <= 0) {
+ return {}
+ }
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
+
+ try {
+ const response = await fetch(infoUrl, {
+ headers: {
+ Accept: "text/html, application/xhtml+xml, */*;q=0.1",
+ },
+ redirect: "follow",
+ signal: controller.signal,
+ })
+
+ if (!response.ok) {
+ return {}
+ }
+
+ return extractHtmlMetadata(await response.text(), infoUrl)
+ } catch {
+ return {}
+ } finally {
+ clearTimeout(timeout)
+ }
}
const cacheByRelay = new Map()
@@ -381,84 +363,19 @@ const fetchRelayMetadata = async relayUrl => {
}
const loader = (async () => {
- const infoUrl = relayToInfoUrl(relayUrl)
+ const deadline = Date.now() + REQUEST_TIMEOUT_MS
+ const remainingTimeout = () => Math.max(0, deadline - Date.now())
+ const relay = await withTimeout(fetchRelay(relayUrl), remainingTimeout())
+ let metadata = normalizeRelayMetadata(relay, relayUrl)
- if (!infoUrl) {
- const empty = {}
- setCachedRelayData(relayUrl, empty, NEGATIVE_CACHE_TTL_MS)
- return empty
- }
-
- const controller = new AbortController()
- const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
-
- let metadata = {}
-
- try {
- const response = await fetch(infoUrl, {
- method: "GET",
- headers: {
- Accept: "application/nostr+json, application/json;q=0.9, */*;q=0.1",
- },
- redirect: "follow",
- signal: controller.signal,
- })
-
- if (response.ok) {
- const text = await response.text()
-
- let payload
-
- try {
- const parsed = JSON.parse(text)
- payload = isRecord(parsed) ? parsed : undefined
- } catch {
- payload = undefined
- }
-
- const jsonName = sanitizeText(payload?.name || payload?.title, 80)
- const jsonDescription = sanitizeText(payload?.description, 180)
- const jsonIcon = normalizeImageUrl(payload?.icon || payload?.picture || payload?.image, infoUrl)
-
- metadata = {
- ...(jsonName ? {name: jsonName} : {}),
- ...(jsonDescription ? {description: jsonDescription} : {}),
- ...(jsonIcon ? {icon: jsonIcon} : {}),
- }
-
- if (Object.keys(metadata).length === 0) {
- const htmlName = sanitizeText(decodeHtmlEntities(readHtmlTagContent(text, "title") || ""), 80)
- const htmlDescription = sanitizeText(
- decodeHtmlEntities(readHtmlMetaContent(text, "description") || ""),
- 180,
- )
- const htmlIcon = normalizeImageUrl(
- decodeHtmlEntities(
- readHtmlMetaContent(text, "og:image") ||
- readHtmlMetaContent(text, "twitter:image") ||
- readHtmlIconHref(text) ||
- "",
- ),
- infoUrl,
- )
-
- metadata = {
- ...(htmlName ? {name: htmlName} : {}),
- ...(htmlDescription ? {description: htmlDescription} : {}),
- ...(htmlIcon ? {icon: htmlIcon} : {}),
- }
- }
- }
- } catch {
- metadata = {}
- } finally {
- clearTimeout(timeout)
+ if (!hasMetadata(metadata)) {
+ metadata = await fetchHtmlRelayMetadata(relayUrl, remainingTimeout())
}
setCachedRelayData(
relayUrl,
metadata,
- Object.keys(metadata).length > 0 ? POSITIVE_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS,
+ hasMetadata(metadata) ? POSITIVE_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS,
)
return metadata
@@ -498,17 +415,18 @@ const loadIndexTemplate = async () => {
}
const INDEX_TEMPLATE = await loadIndexTemplate()
+const INDEX_DOCUMENT = load(INDEX_TEMPLATE)
const DEFAULT_PLATFORM_NAME =
sanitizeText(process.env.VITE_PLATFORM_NAME, 80) ||
- sanitizeText(readMetaContent(INDEX_TEMPLATE, "og:title"), 80) ||
- sanitizeText(readMetaContent(INDEX_TEMPLATE, "twitter:title"), 80) ||
+ sanitizeText(readMetaContent(INDEX_DOCUMENT, "og:title"), 80) ||
+ sanitizeText(readMetaContent(INDEX_DOCUMENT, "twitter:title"), 80) ||
"Flotilla"
const DEFAULT_PLATFORM_DESCRIPTION =
sanitizeText(process.env.VITE_PLATFORM_DESCRIPTION, 180) ||
- sanitizeText(readMetaContent(INDEX_TEMPLATE, "description"), 180) ||
+ sanitizeText(readMetaContent(INDEX_DOCUMENT, "description"), 180) ||
"Flotilla is nostr - for communities."
-const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
+const buildInviteDescription = ({spaceName, relayDisplay, relayDescription}) => {
const parts = []
if (spaceName) {
@@ -517,8 +435,8 @@ const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
parts.push(`You are invited to join a space on ${DEFAULT_PLATFORM_NAME}.`)
}
- if (relayHost) {
- parts.push(`Relay: ${relayHost}.`)
+ if (relayDisplay) {
+ parts.push(`Relay: ${relayDisplay}.`)
}
if (relayDescription) {
@@ -531,47 +449,90 @@ const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
}
const buildInviteMeta = (requestUrl, invite, relayMetadata) => {
- let relayHost = ""
-
- try {
- relayHost = new URL(invite.relayUrl).host
- } catch {
- relayHost = ""
- }
-
+ const relayDisplay = displayRelayUrl(invite.relayUrl)
const spaceName = sanitizeText(relayMetadata.name, 80)
const relayDescription = sanitizeText(relayMetadata.description, 180)
const title = spaceName
? `Invite to ${spaceName} on ${DEFAULT_PLATFORM_NAME}`
: `Invite to a Space on ${DEFAULT_PLATFORM_NAME}`
- const description = buildInviteDescription({spaceName, relayHost, relayDescription})
- const image =
- relayMetadata.icon || absoluteUrlFromRequest(requestUrl, "/maskable-icon-512x512.png")
+ const description = buildInviteDescription({spaceName, relayDisplay, relayDescription})
+ const image = relayMetadata.icon || absoluteUrlFromRequest(requestUrl, DEFAULT_IMAGE_PATH)
const url = requestUrl.toString()
- const site = `${requestUrl.protocol}//${requestUrl.host}`
+ const site = requestUrl.origin
return {title, description, image, url, site}
}
+const ensureHead = $ => {
+ const existingHead = $("head").first()
+
+ if (existingHead.length > 0) {
+ return existingHead
+ }
+
+ $("html").prepend("")
+
+ return $("head").first()
+}
+
+const upsertTitle = ($, title) => {
+ const head = ensureHead($)
+ let titleTag = head.children("title").first()
+
+ if (titleTag.length === 0) {
+ titleTag = $("")
+ head.prepend(titleTag)
+ }
+
+ titleTag.text(title)
+}
+
+const upsertMetaTag = ($, key, content, attribute) => {
+ const head = ensureHead($)
+ let tag = findMeta($, key)
+
+ if (tag.length === 0) {
+ tag = $("")
+ head.append(tag)
+ }
+
+ tag.removeAttr(attribute === "name" ? "property" : "name")
+ tag.attr(attribute, key)
+ tag.attr("content", content)
+}
+
+const upsertCanonical = ($, href) => {
+ const head = ensureHead($)
+ let tag = findLinkRel($, "canonical")
+
+ if (tag.length === 0) {
+ tag = $("")
+ head.append(tag)
+ }
+
+ tag.attr("rel", "canonical")
+ tag.attr("href", href)
+}
+
const injectInviteMeta = (html, metadata) => {
- let output = html
+ const $ = load(html)
- output = upsertTitle(output, metadata.title)
- output = upsertCanonical(output, metadata.url)
- output = upsertMetaTag(output, "description", metadata.description, "name")
- output = upsertMetaTag(output, "og:type", "website", "property")
- output = upsertMetaTag(output, "og:url", metadata.url, "property")
- output = upsertMetaTag(output, "og:title", metadata.title, "property")
- output = upsertMetaTag(output, "og:description", metadata.description, "property")
- output = upsertMetaTag(output, "og:image", metadata.image, "property")
- output = upsertMetaTag(output, "twitter:card", "summary_large_image", "name")
- output = upsertMetaTag(output, "twitter:site", metadata.site, "name")
- output = upsertMetaTag(output, "twitter:url", metadata.url, "name")
- output = upsertMetaTag(output, "twitter:title", metadata.title, "name")
- output = upsertMetaTag(output, "twitter:description", metadata.description, "name")
- output = upsertMetaTag(output, "twitter:image", metadata.image, "name")
+ upsertTitle($, metadata.title)
+ upsertCanonical($, metadata.url)
+ upsertMetaTag($, "description", metadata.description, "name")
+ upsertMetaTag($, "og:type", "website", "property")
+ upsertMetaTag($, "og:url", metadata.url, "property")
+ upsertMetaTag($, "og:title", metadata.title, "property")
+ upsertMetaTag($, "og:description", metadata.description, "property")
+ upsertMetaTag($, "og:image", metadata.image, "property")
+ upsertMetaTag($, "twitter:card", "summary_large_image", "name")
+ upsertMetaTag($, "twitter:site", metadata.site, "name")
+ upsertMetaTag($, "twitter:url", metadata.url, "name")
+ upsertMetaTag($, "twitter:title", metadata.title, "name")
+ upsertMetaTag($, "twitter:description", metadata.description, "name")
+ upsertMetaTag($, "twitter:image", metadata.image, "name")
- return output
+ return $.html()
}
const renderIndex = async requestUrl => {
@@ -591,94 +552,64 @@ const renderIndex = async requestUrl => {
return injectInviteMeta(INDEX_TEMPLATE, inviteMeta)
}
-const notFound = (response, message = "Not found") => {
- response.statusCode = 404
- response.setHeader("Content-Type", "text/plain; charset=utf-8")
- response.end(message)
-}
+const isImmutableAsset = filePath => filePath.split(path.sep).join("/").includes("/_app/immutable/")
-const sendHtml = (response, html, method, dynamic) => {
- response.statusCode = 200
- response.setHeader("Content-Type", "text/html; charset=utf-8")
- response.setHeader("Cache-Control", dynamic ? "no-store" : "no-cache")
+const getStaticCacheControl = filePath =>
+ path.basename(filePath) === "index.html"
+ ? "no-cache"
+ : isImmutableAsset(filePath)
+ ? "public, max-age=31536000, immutable"
+ : "public, max-age=3600"
- if (method === "HEAD") {
- response.end()
- return
- }
-
- response.end(html)
-}
-
-const sendStaticFile = (response, filePath, size, method) => {
- const extension = path.extname(filePath).toLowerCase()
- const mimeType = MIME_TYPES[extension] || "application/octet-stream"
- const immutableAsset = filePath.includes(`${path.sep}_app${path.sep}immutable${path.sep}`)
-
- response.statusCode = 200
- response.setHeader("Content-Type", mimeType)
- response.setHeader("Content-Length", String(size))
- response.setHeader(
- "Cache-Control",
- immutableAsset ? "public, max-age=31536000, immutable" : "public, max-age=3600",
- )
-
- if (method === "HEAD") {
- response.end()
- return
- }
-
- const stream = createReadStream(filePath)
-
- stream.on("error", () => {
- if (!response.headersSent) {
- response.statusCode = 500
- response.end("Internal Server Error")
- return
- }
-
- response.destroy()
+const respondHtml = (html, isHeadRequest, cacheControl) =>
+ new Response(isHeadRequest ? undefined : html, {
+ headers: {
+ ...HTML_HEADERS,
+ "Cache-Control": cacheControl,
+ },
+ status: 200,
})
- stream.pipe(response)
-}
+const app = new Hono()
-const server = http.createServer(async (request, response) => {
- try {
- const method = request.method || "GET"
-
- if (!["GET", "HEAD"].includes(method)) {
- response.statusCode = 405
- response.setHeader("Allow", "GET, HEAD")
- response.end("Method Not Allowed")
- return
- }
-
- const origin = requestOrigin(request)
- const requestUrl = new URL(request.url || "/", origin)
- const staticFile = await findStaticFile(requestUrl.pathname)
-
- if (staticFile) {
- sendStaticFile(response, staticFile.filePath, staticFile.size, method)
- return
- }
-
- if (path.extname(requestUrl.pathname)) {
- notFound(response)
- return
- }
-
- const dynamicInvite = isJoinInvitePath(requestUrl.pathname) && requestUrl.searchParams.has("r")
- const html = await renderIndex(requestUrl)
-
- sendHtml(response, html, method, dynamicInvite)
- } catch {
- response.statusCode = 500
- response.setHeader("Content-Type", "text/plain; charset=utf-8")
- response.end("Internal Server Error")
+app.use("*", async (context, next) => {
+ if (!ALLOWED_METHODS.has(context.req.method)) {
+ return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
}
+
+ await next()
})
-server.listen(PORT, HOST, () => {
- console.log(`Flotilla server running on http://${HOST}:${PORT}`)
+app.use(
+ "*",
+ serveStatic({
+ root: BUILD_DIR,
+ onFound: (filePath, context) => {
+ context.header("Cache-Control", getStaticCacheControl(filePath))
+ },
+ }),
+)
+
+app.on(["GET", "HEAD"], "*", async context => {
+ const requestUrl = requestUrlFromContext(context)
+
+ if (path.extname(requestUrl.pathname)) {
+ return context.text("Not found", 404)
+ }
+
+ const dynamicInvite = isJoinInvitePath(requestUrl.pathname) && requestUrl.searchParams.has("r")
+ const html = await renderIndex(requestUrl)
+
+ return respondHtml(html, context.req.method === "HEAD", dynamicInvite ? "no-store" : "no-cache")
})
+
+serve(
+ {
+ fetch: app.fetch,
+ hostname: HOST,
+ port: PORT,
+ },
+ () => {
+ console.log(`Flotilla server running on http://${HOST}:${PORT}`)
+ },
+)